diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..70f9eaeb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.crates-io] +protocol = "sparse" diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..0a329a8c --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +--- +Language: Proto +BasedOnStyle: Google diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..470b306f --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,10 @@ +[profile.default] +test-threads = "num-cpus" +slow-timeout = { period = "5s", terminate-after = 3 } + +[test-groups] +serial = { max-threads = 1 } + +[[profile.default.overrides]] +filter = 'test(serial)' +test-group = 'serial' diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 9e277f6f..c44edac9 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -5,13 +5,13 @@ on: paths: - "**/Cargo.toml" - "**/Cargo.lock" - - "**/yarn.lock" + - "**/pnpm-lock.yaml" - "**/package.json" push: paths: - "**/Cargo.toml" - "**/Cargo.lock" - - "**/yarn.lock" + - "**/pnpm-lock.yaml" - "**/package.json" workflow_dispatch: schedule: @@ -27,17 +27,26 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions-rs/audit-check@v1 + - name: Install Mask + run: | + curl -sL https://github.com/jacobdeichert/mask/releases/download/v0.11.3/mask-v0.11.3-x86_64-unknown-linux-gnu.zip -o mask.zip && \ + unzip mask.zip && \ + mv mask-v0.11.3-x86_64-unknown-linux-gnu/mask /usr/local/bin/mask && \ + chmod +x /usr/local/bin/mask && \ + rm -rf mask.zip mask-v0.11.3-x86_64-unknown-linux-gnu + + - uses: pnpm/action-setup@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + version: latest - name: Install Node uses: actions/setup-node@v3 with: - cache: "yarn" + cache: "pnpm" node-version: 18 - - name: Yarn Audit - uses: borales/actions-yarn@v4 - with: - cmd: audit + - name: Install dependencies + run: pnpm install + + - name: Audit + run: mask audit diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 07d6c2a4..b5a44998 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -12,13 +12,18 @@ jobs: with: fetch-depth: 0 + - uses: pnpm/action-setup@v2 + with: + version: latest + - name: Setup Node uses: actions/setup-node@v3 with: + cache: 'pnpm' node-version: 18.x - name: Install dependencies - run: yarn install + run: pnpm install - name: Validate all commits from PR run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 63b065c9..460ac815 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -50,29 +50,45 @@ jobs: if: ${{ needs.should_run.outputs.should-run == 'true' }} environment: ${{ (github.ref != 'refs/heads/main' && github.ref != 'refs/heads/feature/*' && github.event_name != 'workflow_dispatch') && 'lint-test-build' || '' }} services: - postgres: - image: postgres:15.2 + cockroach: + image: ghcr.io/scuffletv/cockroach:latest + ports: + - 26257:26257 + rmq: + image: bitnami/rabbitmq:latest + ports: + - 5672:5672 env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres + RABBITMQ_USERNAME: rabbitmq + RABBITMQ_PASSWORD: rabbitmq + RABBITMQ_VHOSTS: scuffle + redis: + image: redis:latest ports: - - 5432:5432 - + - 6379:6379 env: - DATABASE_URL: postgres://postgres:postgres@postgres:5432/scuffle + DATABASE_URL: postgres://root@cockroach:26257/scuffle + RMQ_URL: amqp://rabbitmq:rabbitmq@rmq:5672/scuffle + REDIS_URL: redis://redis:6379 steps: - uses: actions/checkout@v3 with: fetch-depth: 0 + - uses: pnpm/action-setup@v2 + with: + version: latest + - name: Install Node uses: actions/setup-node@v3 with: - cache: "yarn" + cache: "pnpm" node-version: 18 + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + - name: Cargo Cache uses: Swatinem/rust-cache@v2 continue-on-error: false @@ -80,7 +96,7 @@ jobs: prefix-key: lint-test - name: Install dependencies - run: mask bootstrap --no-db --no-docker --no-env --no-stack --no-rust + run: mask bootstrap --no-db --no-docker --no-env --no-rust - name: Run migrations run: mask db migrate @@ -89,7 +105,7 @@ jobs: run: mask lint - name: Run Test Rust - run: mask test --no-js + run: mask test --no-js --ci - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 @@ -98,6 +114,9 @@ jobs: files: lcov.info fail_ci_if_error: true + - name: Install Playwright Dependencies + run: pnpm --filter website exec playwright install-deps + - name: Run Test JavaScript run: mask test --no-rust @@ -112,10 +131,14 @@ jobs: with: fetch-depth: 0 + - uses: pnpm/action-setup@v2 + with: + version: latest + - name: Setup Node uses: actions/setup-node@v3 with: - cache: "yarn" + cache: "pnpm" node-version: 18 - name: Cargo Cache @@ -125,7 +148,7 @@ jobs: prefix-key: build - name: Install dependencies - run: mask bootstrap --no-db --no-docker --no-env --no-js-tests --no-stack --no-rust + run: mask bootstrap --no-db --no-docker --no-env --no-js-tests --no-rust - name: Run Build run: mask build @@ -141,6 +164,9 @@ jobs: target/x86_64-unknown-linux-gnu/release/edge target/x86_64-unknown-linux-gnu/release/transcoder frontend/website/build + frontend/player/dist + frontend/player/pkg + frontend/player/demo-dist docker: name: Build docker images diff --git a/.gitignore b/.gitignore index 03fbdd56..4efdc4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ target/ !.vscode/extensions.json node_modules/ .env* -dev-stack/docker-compose.yml *.log .DS_Store *.lcov diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index c814f232..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -mask lint diff --git a/.sqlx/query-035868368a1a31c2ebbe29cf6f8838c53fe59545aeb1addd2c55628db7c882de.json b/.sqlx/query-035868368a1a31c2ebbe29cf6f8838c53fe59545aeb1addd2c55628db7c882de.json index c2707cb5..32b30a0e 100644 --- a/.sqlx/query-035868368a1a31c2ebbe29cf6f8838c53fe59545aeb1addd2c55628db7c882de.json +++ b/.sqlx/query-035868368a1a31c2ebbe29cf6f8838c53fe59545aeb1addd2c55628db7c882de.json @@ -6,12 +6,12 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 2, @@ -35,7 +35,7 @@ } ], "parameters": { - "Left": ["Int8", "Timestamptz"] + "Left": ["Uuid", "Timestamptz"] }, "nullable": [false, false, true, false, false, false] }, diff --git a/.sqlx/query-05099b839bff31a75798c381868260aab2157b684575f49c861c0c3700b61d38.json b/.sqlx/query-05099b839bff31a75798c381868260aab2157b684575f49c861c0c3700b61d38.json index 252d07d3..b9830cd0 100644 --- a/.sqlx/query-05099b839bff31a75798c381868260aab2157b684575f49c861c0c3700b61d38.json +++ b/.sqlx/query-05099b839bff31a75798c381868260aab2157b684575f49c861c0c3700b61d38.json @@ -6,12 +6,12 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 2, @@ -35,7 +35,7 @@ } ], "parameters": { - "Left": ["Int8Array"] + "Left": ["UuidArray"] }, "nullable": [false, false, true, false, false, false] }, diff --git a/.sqlx/query-0c1620e99580f2f2903044790957d30670e4171385faa0010c3cf3efbcfb0c07.json b/.sqlx/query-0c1620e99580f2f2903044790957d30670e4171385faa0010c3cf3efbcfb0c07.json new file mode 100644 index 00000000..1e8397e8 --- /dev/null +++ b/.sqlx/query-0c1620e99580f2f2903044790957d30670e4171385faa0010c3cf3efbcfb0c07.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM global_role_grants", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "0c1620e99580f2f2903044790957d30670e4171385faa0010c3cf3efbcfb0c07" +} diff --git a/.sqlx/query-108b50ed6503dfdf87eb8cc1e3621ec1a3cfe52b2feb730443aeb5901b3cf8fa.json b/.sqlx/query-108b50ed6503dfdf87eb8cc1e3621ec1a3cfe52b2feb730443aeb5901b3cf8fa.json new file mode 100644 index 00000000..e22d4b66 --- /dev/null +++ b/.sqlx/query-108b50ed6503dfdf87eb8cc1e3621ec1a3cfe52b2feb730443aeb5901b3cf8fa.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM stream_events WHERE stream_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "stream_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "message", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "level", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, false, false, false] + }, + "hash": "108b50ed6503dfdf87eb8cc1e3621ec1a3cfe52b2feb730443aeb5901b3cf8fa" +} diff --git a/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json b/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json index d07204ad..69b8f1d0 100644 --- a/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json +++ b/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json @@ -4,7 +4,7 @@ "describe": { "columns": [], "parameters": { - "Left": ["Int8"] + "Left": ["Uuid"] }, "nullable": [] }, diff --git a/.sqlx/query-1b7580d4870e1d43d80297bff6a45a7d9e2dc32da6fdd1fa5c23576ce46cdeb0.json b/.sqlx/query-1b7580d4870e1d43d80297bff6a45a7d9e2dc32da6fdd1fa5c23576ce46cdeb0.json deleted file mode 100644 index a29986e0..00000000 --- a/.sqlx/query-1b7580d4870e1d43d80297bff6a45a7d9e2dc32da6fdd1fa5c23576ce46cdeb0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": ["Int8", "Varchar", "Varchar", "Varchar"] - }, - "nullable": [] - }, - "hash": "1b7580d4870e1d43d80297bff6a45a7d9e2dc32da6fdd1fa5c23576ce46cdeb0" -} diff --git a/.sqlx/query-26e7e05427bc7dabcd7815d27764fda2baf4cfe60a2d2d6ee2a1f773dccbbce2.json b/.sqlx/query-26e7e05427bc7dabcd7815d27764fda2baf4cfe60a2d2d6ee2a1f773dccbbce2.json index 18e73942..592bf626 100644 --- a/.sqlx/query-26e7e05427bc7dabcd7815d27764fda2baf4cfe60a2d2d6ee2a1f773dccbbce2.json +++ b/.sqlx/query-26e7e05427bc7dabcd7815d27764fda2baf4cfe60a2d2d6ee2a1f773dccbbce2.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, @@ -15,26 +15,56 @@ }, { "ordinal": 2, - "name": "password_hash", + "name": "display_name", "type_info": "Varchar" }, { "ordinal": 3, - "name": "email", + "name": "password_hash", "type_info": "Varchar" }, { "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 12, "name": "last_login_at", "type_info": "Timestamptz" } @@ -42,7 +72,21 @@ "parameters": { "Left": [] }, - "nullable": [false, false, false, false, false, false, false] + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] }, "hash": "26e7e05427bc7dabcd7815d27764fda2baf4cfe60a2d2d6ee2a1f773dccbbce2" } diff --git a/.sqlx/query-2c74978cd2c9e2fd4aee55e5b6e7383db42079d2d9e2ca49d5f5c61223d91fc4.json b/.sqlx/query-2c74978cd2c9e2fd4aee55e5b6e7383db42079d2d9e2ca49d5f5c61223d91fc4.json index f10f0863..e7d13f88 100644 --- a/.sqlx/query-2c74978cd2c9e2fd4aee55e5b6e7383db42079d2d9e2ca49d5f5c61223d91fc4.json +++ b/.sqlx/query-2c74978cd2c9e2fd4aee55e5b6e7383db42079d2d9e2ca49d5f5c61223d91fc4.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, @@ -15,34 +15,78 @@ }, { "ordinal": 2, - "name": "password_hash", + "name": "display_name", "type_info": "Varchar" }, { "ordinal": 3, - "name": "email", + "name": "password_hash", "type_info": "Varchar" }, { "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 12, "name": "last_login_at", "type_info": "Timestamptz" } ], "parameters": { - "Left": ["TextArray"] + "Left": ["VarcharArray"] }, - "nullable": [false, false, false, false, false, false, false] + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] }, "hash": "2c74978cd2c9e2fd4aee55e5b6e7383db42079d2d9e2ca49d5f5c61223d91fc4" } diff --git a/.sqlx/query-368cd60692561bd636df964fa09d77d7daac755a0b151756789618176c4aeb46.json b/.sqlx/query-368cd60692561bd636df964fa09d77d7daac755a0b151756789618176c4aeb46.json new file mode 100644 index 00000000..61ec7ff7 --- /dev/null +++ b/.sqlx/query-368cd60692561bd636df964fa09d77d7daac755a0b151756789618176c4aeb46.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [false] + }, + "hash": "368cd60692561bd636df964fa09d77d7daac755a0b151756789618176c4aeb46" +} diff --git a/.sqlx/query-3b08b0a28ec56eecf0fefbf7248c5fa75ff3ff7bd2ce5366df634fd4d2938cd2.json b/.sqlx/query-3b08b0a28ec56eecf0fefbf7248c5fa75ff3ff7bd2ce5366df634fd4d2938cd2.json new file mode 100644 index 00000000..7389ead9 --- /dev/null +++ b/.sqlx/query-3b08b0a28ec56eecf0fefbf7248c5fa75ff3ff7bd2ce5366df634fd4d2938cd2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET state = $2, updated_at = $3, ended_at = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Int8", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "3b08b0a28ec56eecf0fefbf7248c5fa75ff3ff7bd2ce5366df634fd4d2938cd2" +} diff --git a/.sqlx/query-3b7a241164f959d566e9e3944e23f515377ed87813964914f76d7f6c59e831e7.json b/.sqlx/query-3b7a241164f959d566e9e3944e23f515377ed87813964914f76d7f6c59e831e7.json index a42a2844..f0b52979 100644 --- a/.sqlx/query-3b7a241164f959d566e9e3944e23f515377ed87813964914f76d7f6c59e831e7.json +++ b/.sqlx/query-3b7a241164f959d566e9e3944e23f515377ed87813964914f76d7f6c59e831e7.json @@ -6,12 +6,12 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 2, @@ -35,7 +35,7 @@ } ], "parameters": { - "Left": ["Int8"] + "Left": ["Uuid"] }, "nullable": [false, false, true, false, false, false] }, diff --git a/.sqlx/query-3e75dfea0d1b0bf39900027cc5811fadcaa9f1ac2bcc7ee734036ca5ae80ba3f.json b/.sqlx/query-3e75dfea0d1b0bf39900027cc5811fadcaa9f1ac2bcc7ee734036ca5ae80ba3f.json new file mode 100644 index 00000000..48f140c3 --- /dev/null +++ b/.sqlx/query-3e75dfea0d1b0bf39900027cc5811fadcaa9f1ac2bcc7ee734036ca5ae80ba3f.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id, ended_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "channel_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "recorded", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "transcoded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "state", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "ingest_address", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "connection_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "ended_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid", "Varchar", "Text", "Bool", "Bool", "Varchar", "Uuid", "Timestamptz"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "3e75dfea0d1b0bf39900027cc5811fadcaa9f1ac2bcc7ee734036ca5ae80ba3f" +} diff --git a/.sqlx/query-4d7199d35d15dd51799ad2f7e568c92b6facabe6dfd9fe64328f426a08471904.json b/.sqlx/query-4d7199d35d15dd51799ad2f7e568c92b6facabe6dfd9fe64328f426a08471904.json new file mode 100644 index 00000000..8f516663 --- /dev/null +++ b/.sqlx/query-4d7199d35d15dd51799ad2f7e568c92b6facabe6dfd9fe64328f426a08471904.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET updated_at = $2, ended_at = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Timestamptz", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "4d7199d35d15dd51799ad2f7e568c92b6facabe6dfd9fe64328f426a08471904" +} diff --git a/.sqlx/query-512142ff15edf2c33cc8680612c751438ff65e0a47dd03fe849489f2ad8f7dea.json b/.sqlx/query-512142ff15edf2c33cc8680612c751438ff65e0a47dd03fe849489f2ad8f7dea.json new file mode 100644 index 00000000..2f953129 --- /dev/null +++ b/.sqlx/query-512142ff15edf2c33cc8680612c751438ff65e0a47dd03fe849489f2ad8f7dea.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "512142ff15edf2c33cc8680612c751438ff65e0a47dd03fe849489f2ad8f7dea" +} diff --git a/.sqlx/query-54050915e148f50b9c91eceb43d1ecd85761b9ae02d0b04f05b6360ba1132567.json b/.sqlx/query-54050915e148f50b9c91eceb43d1ecd85761b9ae02d0b04f05b6360ba1132567.json new file mode 100644 index 00000000..b96b1deb --- /dev/null +++ b/.sqlx/query-54050915e148f50b9c91eceb43d1ecd85761b9ae02d0b04f05b6360ba1132567.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO streams (id, channel_id, title, description, state, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid", "Varchar", "Text", "Int8", "Varchar", "Uuid"] + }, + "nullable": [] + }, + "hash": "54050915e148f50b9c91eceb43d1ecd85761b9ae02d0b04f05b6360ba1132567" +} diff --git a/.sqlx/query-5621eadc9d47de01630d508892861befd9ac13055a4b5c1654e3e2dc49870ca2.json b/.sqlx/query-5621eadc9d47de01630d508892861befd9ac13055a4b5c1654e3e2dc49870ca2.json deleted file mode 100644 index 831baa4c..00000000 --- a/.sqlx/query-5621eadc9d47de01630d508892861befd9ac13055a4b5c1654e3e2dc49870ca2.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "password_hash", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "email_verified", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "last_login_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": ["Int8", "Varchar", "Varchar", "Varchar"] - }, - "nullable": [false, false, false, false, false, false, false] - }, - "hash": "5621eadc9d47de01630d508892861befd9ac13055a4b5c1654e3e2dc49870ca2" -} diff --git a/.sqlx/query-5e0e4e682a9bba975af52bdb80b64573bc7c54a97459402865aec00b109026b0.json b/.sqlx/query-5e0e4e682a9bba975af52bdb80b64573bc7c54a97459402865aec00b109026b0.json new file mode 100644 index 00000000..1d954f30 --- /dev/null +++ b/.sqlx/query-5e0e4e682a9bba975af52bdb80b64573bc7c54a97459402865aec00b109026b0.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET state = 0, ended_at = $2 WHERE id = $1;", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "5e0e4e682a9bba975af52bdb80b64573bc7c54a97459402865aec00b109026b0" +} diff --git a/.sqlx/query-614fafd36514d4d678c746372ff86c839dfb155eadc4c769266ce6fc259aa622.json b/.sqlx/query-614fafd36514d4d678c746372ff86c839dfb155eadc4c769266ce6fc259aa622.json new file mode 100644 index 00000000..6e00da49 --- /dev/null +++ b/.sqlx/query-614fafd36514d4d678c746372ff86c839dfb155eadc4c769266ce6fc259aa622.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "614fafd36514d4d678c746372ff86c839dfb155eadc4c769266ce6fc259aa622" +} diff --git a/.sqlx/query-66b22ce14a23a871257906fa260a4dfbe8a60b781d89bb12199dcc9a249e7fbd.json b/.sqlx/query-66b22ce14a23a871257906fa260a4dfbe8a60b781d89bb12199dcc9a249e7fbd.json new file mode 100644 index 00000000..593130e3 --- /dev/null +++ b/.sqlx/query-66b22ce14a23a871257906fa260a4dfbe8a60b781d89bb12199dcc9a249e7fbd.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET state = $2, ended_at = NOW(), updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Int8"] + }, + "nullable": [] + }, + "hash": "66b22ce14a23a871257906fa260a4dfbe8a60b781d89bb12199dcc9a249e7fbd" +} diff --git a/.sqlx/query-67212733127cc5b54c1f800575c07bee774ac3ccd41001f508235aaf016969ff.json b/.sqlx/query-67212733127cc5b54c1f800575c07bee774ac3ccd41001f508235aaf016969ff.json new file mode 100644 index 00000000..c8470485 --- /dev/null +++ b/.sqlx/query-67212733127cc5b54c1f800575c07bee774ac3ccd41001f508235aaf016969ff.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO stream_events (stream_id, level, title, message, created_at) VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Int8", "Varchar", "Text", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "67212733127cc5b54c1f800575c07bee774ac3ccd41001f508235aaf016969ff" +} diff --git a/.sqlx/query-712cc809af0f6a9fdb91d78a602fd2e79644cbbfc101813599f341980769d860.json b/.sqlx/query-712cc809af0f6a9fdb91d78a602fd2e79644cbbfc101813599f341980769d860.json new file mode 100644 index 00000000..c95d9c9e --- /dev/null +++ b/.sqlx/query-712cc809af0f6a9fdb91d78a602fd2e79644cbbfc101813599f341980769d860.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO stream_variants (id, name, stream_id, audio_bitrate, audio_channels, audio_sample_rate, video_bitrate, video_framerate, video_height, video_width, audio_codec, video_codec, created_at, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,$12, $13, $14)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Uuid", + "Int8", + "Int8", + "Int8", + "Int8", + "Int8", + "Int8", + "Int8", + "Varchar", + "Varchar", + "Timestamptz", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "712cc809af0f6a9fdb91d78a602fd2e79644cbbfc101813599f341980769d860" +} diff --git a/.sqlx/query-750acae3f638bd028e48f41a955d665914e8cf47383fda2b212183c13e385250.json b/.sqlx/query-750acae3f638bd028e48f41a955d665914e8cf47383fda2b212183c13e385250.json deleted file mode 100644 index af59d9b6..00000000 --- a/.sqlx/query-750acae3f638bd028e48f41a955d665914e8cf47383fda2b212183c13e385250.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "invalidated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "last_used_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": ["Int8", "Int8", "Timestamptz"] - }, - "nullable": [false, false, true, false, false, false] - }, - "hash": "750acae3f638bd028e48f41a955d665914e8cf47383fda2b212183c13e385250" -} diff --git a/.sqlx/query-7e9ff951aeb95580a0e33bd1b82f3cafb3f1cbee9002c2a6ab4306890c5fd563.json b/.sqlx/query-7e9ff951aeb95580a0e33bd1b82f3cafb3f1cbee9002c2a6ab4306890c5fd563.json new file mode 100644 index 00000000..ad6343c2 --- /dev/null +++ b/.sqlx/query-7e9ff951aeb95580a0e33bd1b82f3cafb3f1cbee9002c2a6ab4306890c5fd563.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT rg.user_id, r.* FROM global_role_grants rg JOIN global_roles r ON rg.global_role_id = r.id WHERE rg.user_id = ANY($1) ORDER BY rg.user_id, r.rank ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "rank", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "allowed_permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "denied_permissions", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["UuidArray"] + }, + "nullable": [false, false, false, false, false, false, false, false] + }, + "hash": "7e9ff951aeb95580a0e33bd1b82f3cafb3f1cbee9002c2a6ab4306890c5fd563" +} diff --git a/.sqlx/query-7f424c4cb2f5ba376b1397fa03f3b8cd6d26fdef16a6a5c385595ecabd6c67f9.json b/.sqlx/query-7f424c4cb2f5ba376b1397fa03f3b8cd6d26fdef16a6a5c385595ecabd6c67f9.json index d54724c1..f8fb48b0 100644 --- a/.sqlx/query-7f424c4cb2f5ba376b1397fa03f3b8cd6d26fdef16a6a5c385595ecabd6c67f9.json +++ b/.sqlx/query-7f424c4cb2f5ba376b1397fa03f3b8cd6d26fdef16a6a5c385595ecabd6c67f9.json @@ -4,7 +4,7 @@ "describe": { "columns": [], "parameters": { - "Left": ["Int8"] + "Left": ["Uuid"] }, "nullable": [] }, diff --git a/.sqlx/query-8ad0d841aef4b91bdc8f7944fdd6681791e233abe990672413f4f985769e9557.json b/.sqlx/query-8ad0d841aef4b91bdc8f7944fdd6681791e233abe990672413f4f985769e9557.json new file mode 100644 index 00000000..6ed7a318 --- /dev/null +++ b/.sqlx/query-8ad0d841aef4b91bdc8f7944fdd6681791e233abe990672413f4f985769e9557.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM streams WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "channel_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "recorded", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "transcoded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "state", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "ingest_address", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "connection_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "ended_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["UuidArray"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "8ad0d841aef4b91bdc8f7944fdd6681791e233abe990672413f4f985769e9557" +} diff --git a/.sqlx/query-8c16c76efc00d25c3b8f9a3c612268af26b634cd7af07a2dc900a98895dbc32b.json b/.sqlx/query-8c16c76efc00d25c3b8f9a3c612268af26b634cd7af07a2dc900a98895dbc32b.json deleted file mode 100644 index eb14a8c6..00000000 --- a/.sqlx/query-8c16c76efc00d25c3b8f9a3c612268af26b634cd7af07a2dc900a98895dbc32b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": ["Int8", "Varchar", "Varchar", "Varchar"] - }, - "nullable": [] - }, - "hash": "8c16c76efc00d25c3b8f9a3c612268af26b634cd7af07a2dc900a98895dbc32b" -} diff --git a/.sqlx/query-9694b996490453d5084dd3e1a39fd50ee8b118112b76815247a0b88ecc229334.json b/.sqlx/query-9694b996490453d5084dd3e1a39fd50ee8b118112b76815247a0b88ecc229334.json new file mode 100644 index 00000000..7b10ffac --- /dev/null +++ b/.sqlx/query-9694b996490453d5084dd3e1a39fd50ee8b118112b76815247a0b88ecc229334.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM global_roles", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "9694b996490453d5084dd3e1a39fd50ee8b118112b76815247a0b88ecc229334" +} diff --git a/.sqlx/query-9b57fecce896b1fbef36e9f274f073342913ef8f4c765db9ede719c80d0baba0.json b/.sqlx/query-9b57fecce896b1fbef36e9f274f073342913ef8f4c765db9ede719c80d0baba0.json new file mode 100644 index 00000000..2fa1e51d --- /dev/null +++ b/.sqlx/query-9b57fecce896b1fbef36e9f274f073342913ef8f4c765db9ede719c80d0baba0.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM stream_bitrate_updates WHERE stream_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "stream_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "video_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "audio_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "metadata_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, false, false] + }, + "hash": "9b57fecce896b1fbef36e9f274f073342913ef8f4c765db9ede719c80d0baba0" +} diff --git a/.sqlx/query-a1e8e41b7c5e2cd81b351bb9bbc5276ab2b660a890a87249a062f44d7515b3bc.json b/.sqlx/query-a1e8e41b7c5e2cd81b351bb9bbc5276ab2b660a890a87249a062f44d7515b3bc.json new file mode 100644 index 00000000..1df5402b --- /dev/null +++ b/.sqlx/query-a1e8e41b7c5e2cd81b351bb9bbc5276ab2b660a890a87249a062f44d7515b3bc.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Varchar", "Text", "Int8", "Int8", "Int8", "Timestamptz"] + }, + "nullable": [false] + }, + "hash": "a1e8e41b7c5e2cd81b351bb9bbc5276ab2b660a890a87249a062f44d7515b3bc" +} diff --git a/.sqlx/query-764a0670aee7f863f22fda6cba6bf40dda167ebee5756aff541e243be4b1bb0e.json b/.sqlx/query-a8d689f3da99ca8dd832facc7aca3e4a766ccaa66afbddb3bbb4334df2a18b79.json similarity index 51% rename from .sqlx/query-764a0670aee7f863f22fda6cba6bf40dda167ebee5756aff541e243be4b1bb0e.json rename to .sqlx/query-a8d689f3da99ca8dd832facc7aca3e4a766ccaa66afbddb3bbb4334df2a18b79.json index 792fff4e..28f9385b 100644 --- a/.sqlx/query-764a0670aee7f863f22fda6cba6bf40dda167ebee5756aff541e243be4b1bb0e.json +++ b/.sqlx/query-a8d689f3da99ca8dd832facc7aca3e4a766ccaa66afbddb3bbb4334df2a18b79.json @@ -1,48 +1,48 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (username, password_hash, email) VALUES ($1, $2, $3) RETURNING *", + "query": "SELECT * FROM global_roles WHERE rank = -1", "describe": { "columns": [ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, - "name": "username", + "name": "name", "type_info": "Varchar" }, { "ordinal": 2, - "name": "password_hash", - "type_info": "Varchar" + "name": "description", + "type_info": "Text" }, { "ordinal": 3, - "name": "email", - "type_info": "Varchar" + "name": "rank", + "type_info": "Int8" }, { "ordinal": 4, - "name": "email_verified", - "type_info": "Bool" + "name": "allowed_permissions", + "type_info": "Int8" }, { "ordinal": 5, - "name": "created_at", - "type_info": "Timestamptz" + "name": "denied_permissions", + "type_info": "Int8" }, { "ordinal": 6, - "name": "last_login_at", + "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { - "Left": ["Varchar", "Varchar", "Varchar"] + "Left": [] }, "nullable": [false, false, false, false, false, false, false] }, - "hash": "764a0670aee7f863f22fda6cba6bf40dda167ebee5756aff541e243be4b1bb0e" + "hash": "a8d689f3da99ca8dd832facc7aca3e4a766ccaa66afbddb3bbb4334df2a18b79" } diff --git a/.sqlx/query-b0e75a4049dd4ffe01458ac90cba1ea4d89adc76be24f485bfed5b80e49827f4.json b/.sqlx/query-b0e75a4049dd4ffe01458ac90cba1ea4d89adc76be24f485bfed5b80e49827f4.json new file mode 100644 index 00000000..e4710381 --- /dev/null +++ b/.sqlx/query-b0e75a4049dd4ffe01458ac90cba1ea4d89adc76be24f485bfed5b80e49827f4.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b0e75a4049dd4ffe01458ac90cba1ea4d89adc76be24f485bfed5b80e49827f4" +} diff --git a/.sqlx/query-b21c95c9715a07d176c595b6e288c723185d9c115dccd52820432061c3d726b3.json b/.sqlx/query-b21c95c9715a07d176c595b6e288c723185d9c115dccd52820432061c3d726b3.json new file mode 100644 index 00000000..54e4d43e --- /dev/null +++ b/.sqlx/query-b21c95c9715a07d176c595b6e288c723185d9c115dccd52820432061c3d726b3.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [] + }, + "hash": "b21c95c9715a07d176c595b6e288c723185d9c115dccd52820432061c3d726b3" +} diff --git a/.sqlx/query-b2e203f1efe61c600ef99fce94875a138dc2a764d6e89860de0c2be54c8e6273.json b/.sqlx/query-b2e203f1efe61c600ef99fce94875a138dc2a764d6e89860de0c2be54c8e6273.json new file mode 100644 index 00000000..71991426 --- /dev/null +++ b/.sqlx/query-b2e203f1efe61c600ef99fce94875a138dc2a764d6e89860de0c2be54c8e6273.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "channel_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "recorded", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "transcoded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "state", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "ingest_address", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "connection_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "ended_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid", "Varchar", "Text", "Bool", "Bool", "Varchar", "Uuid"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "b2e203f1efe61c600ef99fce94875a138dc2a764d6e89860de0c2be54c8e6273" +} diff --git a/.sqlx/query-b4f47071b16828f14aa4675cd536c7f78a4d44fab3b4d5a3824205f050427487.json b/.sqlx/query-b4f47071b16828f14aa4675cd536c7f78a4d44fab3b4d5a3824205f050427487.json new file mode 100644 index 00000000..f7a57357 --- /dev/null +++ b/.sqlx/query-b4f47071b16828f14aa4675cd536c7f78a4d44fab3b4d5a3824205f050427487.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, display_name, password_hash, email, stream_key) VALUES ($1, $2, $3, $4, $5) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b4f47071b16828f14aa4675cd536c7f78a4d44fab3b4d5a3824205f050427487" +} diff --git a/.sqlx/query-b70317ca36372ae9803b15c675439062654e708b88c838cef53e642b16963bd3.json b/.sqlx/query-b70317ca36372ae9803b15c675439062654e708b88c838cef53e642b16963bd3.json index 08207f17..84928ae8 100644 --- a/.sqlx/query-b70317ca36372ae9803b15c675439062654e708b88c838cef53e642b16963bd3.json +++ b/.sqlx/query-b70317ca36372ae9803b15c675439062654e708b88c838cef53e642b16963bd3.json @@ -6,12 +6,12 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 2, @@ -35,7 +35,7 @@ } ], "parameters": { - "Left": ["Int8", "Timestamptz"] + "Left": ["Uuid", "Timestamptz"] }, "nullable": [false, false, true, false, false, false] }, diff --git a/.sqlx/query-be069c894da4bd83e723cf42f1e9dd81d7415c2daa11bff6b20d4e80ff5a4655.json b/.sqlx/query-be069c894da4bd83e723cf42f1e9dd81d7415c2daa11bff6b20d4e80ff5a4655.json new file mode 100644 index 00000000..8cca7e48 --- /dev/null +++ b/.sqlx/query-be069c894da4bd83e723cf42f1e9dd81d7415c2daa11bff6b20d4e80ff5a4655.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO stream_bitrate_updates (stream_id, video_bitrate, audio_bitrate, metadata_bitrate, created_at) VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Int8", "Int8", "Int8", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "be069c894da4bd83e723cf42f1e9dd81d7415c2daa11bff6b20d4e80ff5a4655" +} diff --git a/.sqlx/query-bff026cc0de142f196a6046e94ae5de4fff7aa4406ff1c9e6143e5642771dc76.json b/.sqlx/query-bff026cc0de142f196a6046e94ae5de4fff7aa4406ff1c9e6143e5642771dc76.json new file mode 100644 index 00000000..38967cba --- /dev/null +++ b/.sqlx/query-bff026cc0de142f196a6046e94ae5de4fff7aa4406ff1c9e6143e5642771dc76.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM stream_variants WHERE stream_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "stream_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "video_framerate", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "video_width", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "video_height", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "video_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "video_codec", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "audio_sample_rate", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "audio_channels", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "audio_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "audio_codec", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "bff026cc0de142f196a6046e94ae5de4fff7aa4406ff1c9e6143e5642771dc76" +} diff --git a/.sqlx/query-cb132bd2a36febc699e69c97c7f2a673e7a58cd4764e5f27bea094dcae6068a9.json b/.sqlx/query-cb132bd2a36febc699e69c97c7f2a673e7a58cd4764e5f27bea094dcae6068a9.json new file mode 100644 index 00000000..43f5548e --- /dev/null +++ b/.sqlx/query-cb132bd2a36febc699e69c97c7f2a673e7a58cd4764e5f27bea094dcae6068a9.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM streams WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "channel_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "recorded", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "transcoded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "deleted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "state", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "ingest_address", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "connection_id", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "ended_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "cb132bd2a36febc699e69c97c7f2a673e7a58cd4764e5f27bea094dcae6068a9" +} diff --git a/.sqlx/query-d130c416e56962ab334ee1b4ca77369a4c35dbc1cf31279f7ed4d418ed75aabb.json b/.sqlx/query-d130c416e56962ab334ee1b4ca77369a4c35dbc1cf31279f7ed4d418ed75aabb.json index 3f9ca330..1c2264b1 100644 --- a/.sqlx/query-d130c416e56962ab334ee1b4ca77369a4c35dbc1cf31279f7ed4d418ed75aabb.json +++ b/.sqlx/query-d130c416e56962ab334ee1b4ca77369a4c35dbc1cf31279f7ed4d418ed75aabb.json @@ -6,12 +6,12 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 2, diff --git a/.sqlx/query-d922fee7a1f6646268b5a3a6feb3f4574d0617d2366d9ecd9ecbaba259d921f2.json b/.sqlx/query-d922fee7a1f6646268b5a3a6feb3f4574d0617d2366d9ecd9ecbaba259d921f2.json new file mode 100644 index 00000000..c91c98e1 --- /dev/null +++ b/.sqlx/query-d922fee7a1f6646268b5a3a6feb3f4574d0617d2366d9ecd9ecbaba259d921f2.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [false] + }, + "hash": "d922fee7a1f6646268b5a3a6feb3f4574d0617d2366d9ecd9ecbaba259d921f2" +} diff --git a/.sqlx/query-dbf86803b7b808f9363c880937fa393e0de85cc77a61c328af0e9afc846e393d.json b/.sqlx/query-dbf86803b7b808f9363c880937fa393e0de85cc77a61c328af0e9afc846e393d.json new file mode 100644 index 00000000..932923e9 --- /dev/null +++ b/.sqlx/query-dbf86803b7b808f9363c880937fa393e0de85cc77a61c328af0e9afc846e393d.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_role_grants (user_id, global_role_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [] + }, + "hash": "dbf86803b7b808f9363c880937fa393e0de85cc77a61c328af0e9afc846e393d" +} diff --git a/.sqlx/query-e4568529cfbdc9207c1ba481ae77489e756927d45b7963842215098d51bc3d0b.json b/.sqlx/query-e4568529cfbdc9207c1ba481ae77489e756927d45b7963842215098d51bc3d0b.json index d49dc1b1..f7b401da 100644 --- a/.sqlx/query-e4568529cfbdc9207c1ba481ae77489e756927d45b7963842215098d51bc3d0b.json +++ b/.sqlx/query-e4568529cfbdc9207c1ba481ae77489e756927d45b7963842215098d51bc3d0b.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "id", - "type_info": "Int8" + "type_info": "Uuid" }, { "ordinal": 1, @@ -15,34 +15,78 @@ }, { "ordinal": 2, - "name": "password_hash", + "name": "display_name", "type_info": "Varchar" }, { "ordinal": 3, - "name": "email", + "name": "password_hash", "type_info": "Varchar" }, { "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 12, "name": "last_login_at", "type_info": "Timestamptz" } ], "parameters": { - "Left": ["Int8Array"] + "Left": ["UuidArray"] }, - "nullable": [false, false, false, false, false, false, false] + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] }, "hash": "e4568529cfbdc9207c1ba481ae77489e756927d45b7963842215098d51bc3d0b" } diff --git a/.sqlx/query-e5070efdfa3b655127859bd6988c53937aca9b25593d7aa50446637b71dcc1c2.json b/.sqlx/query-e5070efdfa3b655127859bd6988c53937aca9b25593d7aa50446637b71dcc1c2.json new file mode 100644 index 00000000..97abe328 --- /dev/null +++ b/.sqlx/query-e5070efdfa3b655127859bd6988c53937aca9b25593d7aa50446637b71dcc1c2.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM stream_variants WHERE stream_id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "stream_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "video_framerate", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "video_width", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "video_height", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "video_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "video_codec", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "audio_sample_rate", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "audio_channels", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "audio_bitrate", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "audio_codec", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["UuidArray"] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "e5070efdfa3b655127859bd6988c53937aca9b25593d7aa50446637b71dcc1c2" +} diff --git a/.sqlx/query-eb0507bd827116e4b1d301ead0a61a814f33017e4c987687cc0bd44201466470.json b/.sqlx/query-eb0507bd827116e4b1d301ead0a61a814f33017e4c987687cc0bd44201466470.json new file mode 100644 index 00000000..b750642b --- /dev/null +++ b/.sqlx/query-eb0507bd827116e4b1d301ead0a61a814f33017e4c987687cc0bd44201466470.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [] + }, + "hash": "eb0507bd827116e4b1d301ead0a61a814f33017e4c987687cc0bd44201466470" +} diff --git a/.sqlx/query-f03bc2e6748e50c26096726e04a309e356161fef6c0ba068dcb7e1b5e884423f.json b/.sqlx/query-f03bc2e6748e50c26096726e04a309e356161fef6c0ba068dcb7e1b5e884423f.json new file mode 100644 index 00000000..81624e02 --- /dev/null +++ b/.sqlx/query-f03bc2e6748e50c26096726e04a309e356161fef6c0ba068dcb7e1b5e884423f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM streams", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "f03bc2e6748e50c26096726e04a309e356161fef6c0ba068dcb7e1b5e884423f" +} diff --git a/.sqlx/query-f384b5f03269060341ac3d10061952ab57a30ab6e37111b855d0dea80fcf022a.json b/.sqlx/query-f384b5f03269060341ac3d10061952ab57a30ab6e37111b855d0dea80fcf022a.json new file mode 100644 index 00000000..cc0595bc --- /dev/null +++ b/.sqlx/query-f384b5f03269060341ac3d10061952ab57a30ab6e37111b855d0dea80fcf022a.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "stream_key", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "stream_title", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "stream_description", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "stream_transcoding_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "stream_recording_enabled", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Varchar", "Varchar", "Varchar", "Varchar"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "f384b5f03269060341ac3d10061952ab57a30ab6e37111b855d0dea80fcf022a" +} diff --git a/.sqlx/query-fd5653c3337f92bbc713dc7a7a002b196c7fb5c0c06ab54aba42d576083a6b8a.json b/.sqlx/query-fd5653c3337f92bbc713dc7a7a002b196c7fb5c0c06ab54aba42d576083a6b8a.json new file mode 100644 index 00000000..336de5b0 --- /dev/null +++ b/.sqlx/query-fd5653c3337f92bbc713dc7a7a002b196c7fb5c0c06ab54aba42d576083a6b8a.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE streams SET state = $2, updated_at = $3, ended_at = $4 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": ["Uuid", "Int8", "Timestamptz", "Timestamptz"] + }, + "nullable": [] + }, + "hash": "fd5653c3337f92bbc713dc7a7a002b196c7fb5c0c06ab54aba42d576083a6b8a" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 184e9a6a..90f131a7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,10 +4,11 @@ "rust-lang.rust-analyzer", "serayuzgur.crates", "bungcip.better-toml", - "prisma.prisma", "swellaby.rust-pack", "svelte.svelte-vscode", "zxh404.vscode-proto3", - "ryanluker.vscode-coverage-gutters" + "ryanluker.vscode-coverage-gutters", + "mtxr.sqltools-driver-pg", + "mtxr.sqltools" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c87a8a5e..8c434d3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,6 @@ This includes: - Installing all the dependencies - Setting up the database -- Setting up the local stack - Setting up .env files ## Development Database @@ -182,34 +181,6 @@ export SCUF_TURNSTILE_SECRET_KEY= Then when you start the API server it will use the local instance of Turnstile. -### Local Stack - -You can setup a local stack with the following command: - -```bash -mask stack init -``` - -You need to have fully built local environment before running this command. You can do that with the following command: - -```bash -mask build -``` - -Or if you want to build it inside a container you can run: - -```bash -mask build --container -``` - -Then to start it run - -``` -mask stack up -``` - -You can modify the stack by editing `./dev-stack/docker-compose.yml` file generated by `mask stack init`. - ## Monorepo For starters, you will notice that this project is a [monorepo](https://semaphoreci.com/blog/what-is-monorepo). diff --git a/Cargo.lock b/Cargo.lock index 70c0ba5c..1b6057fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,17 @@ dependencies = [ "regex", ] +[[package]] +name = "aac" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "num-derive", + "num-traits", +] + [[package]] name = "ahash" version = "0.7.6" @@ -23,15 +34,85 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" -version = "0.7.20" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" dependencies = [ "memchr", ] +[[package]] +name = "amf0" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "num-derive", + "num-traits", +] + +[[package]] +name = "amq-protocol" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d40d8b2465c7959dd40cee32ba6ac334b5de57e9fca0cc756759894a4152a5d" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb2100adae7da61953a2c3a01935d86caae13329fadce3333f524d6d6ce12e2" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "156ff13c8a3ced600b4e54ed826a2ae6242b6069d00dd98466827cef07d3daff" +dependencies = [ + "cookie-factory", + "nom", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751bbd7d440576066233e740576f1b31fdc6ab86cfabfbd48c548de77eca73e4" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -43,9 +124,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.69" +version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" +checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" [[package]] name = "api" @@ -53,12 +134,14 @@ version = "0.1.0" dependencies = [ "anyhow", "arc-swap", + "argon2", "async-graphql", "async-stream", + "bitmask-enum", "chrono", "common", - "coverage-helper", "dotenvy", + "email_address", "futures", "futures-util", "hmac", @@ -66,18 +149,25 @@ dependencies = [ "hyper", "hyper-tungstenite", "jwt", + "lapin", "negative-impl", + "portpicker", + "prost", + "rand", "reqwest", "routerify", "serde", "serde_json", + "serial_test", "sha2", - "sqlx 0.7.0-alpha.2", + "sqlx", "tempfile", - "thiserror", "tokio", "tokio-tungstenite", + "tonic", + "tonic-build", "tracing", + "uuid", ] [[package]] @@ -86,11 +176,17 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" +[[package]] +name = "arcstr" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f907281554a3d0312bb7aab855a8e0ef6cbf1614d06de54105039ca8b34460e" + [[package]] name = "argon2" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73" +checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" dependencies = [ "base64ct", "blake2", @@ -103,11 +199,62 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "async-channel" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-executor" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb" +dependencies = [ + "async-lock", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776" +dependencies = [ + "async-channel", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dd14c5a15affd2abcff50d84efd4009ada28a860f01c14f9d654f3e81b3f75" +dependencies = [ + "async-global-executor", + "async-trait", + "executor-trait", +] + [[package]] name = "async-graphql" -version = "5.0.6" +version = "5.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692d27c9d6fbb7afafd092706cbb3e4a2087297e10e1f0ca82b3f950f31d9258" +checksum = "6ae09afb01514b3dbd6328547b2b11fcbcb0205d9c5e6f2e17e60cb166a82d7f" dependencies = [ "async-graphql-derive", "async-graphql-parser", @@ -142,13 +289,14 @@ dependencies = [ "thiserror", "tracing", "tracing-futures", + "uuid", ] [[package]] name = "async-graphql-derive" -version = "5.0.6" +version = "5.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec10e63a513389190e9f8f32453bfcfeef271e25e841d61905985f838a5345eb" +checksum = "60ae62851dd3ff9a7550aee75e848e8834b75285b458753e98dd71d0733ad3f2" dependencies = [ "Inflector", "async-graphql-parser", @@ -156,15 +304,15 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 1.0.109", "thiserror", ] [[package]] name = "async-graphql-parser" -version = "5.0.6" +version = "5.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79500e9bed6b3cf5e1d3960264b7dbc275dd45b56a3f919c30f0cbbf3ea9cba" +checksum = "9e6ee332acd99d2c50c3443beae46e9ed784c205eead9a668b7b5118b4a60a8b" dependencies = [ "async-graphql-value", "pest", @@ -174,9 +322,9 @@ dependencies = [ [[package]] name = "async-graphql-value" -version = "5.0.6" +version = "5.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14fde4382b75c27fafcaca59b423d4530f73e7f62f41bfa38e8f249026d22ed" +checksum = "122da50452383410545b9428b579f4cda5616feb6aa0aff0003500c53fcff7b7" dependencies = [ "bytes", "indexmap", @@ -184,11 +332,52 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite", + "log", + "parking", + "polling", + "rustix", + "slab", + "socket2", + "waker-fn", +] + +[[package]] +name = "async-lock" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io", + "async-trait", + "futures-core", + "reactor-trait", +] + [[package]] name = "async-stream" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad445822218ce64be7a341abfb0b1ea43b5c23aa83902542a4542e78309d8e5e" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" dependencies = [ "async-stream-impl", "futures-core", @@ -197,43 +386,46 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4655ae1a7b0cdf149156f780c5bf3f1352bc53cbd9e0a361a7ef7b22947e965" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] +[[package]] +name = "async-task" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" + [[package]] name = "async-trait" -version = "0.1.64" +version = "0.1.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd7fce9ba8c3c042128ce72d8b2ddbf3a05747efb67ea0313c635e10bda47a2" +checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] name = "atoi" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ "num-traits", ] [[package]] -name = "atoi" -version = "2.0.0" +name = "atomic-waker" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] +checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" [[package]] name = "atty" @@ -252,6 +444,66 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "av1" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", +] + +[[package]] +name = "axum" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39" +dependencies = [ + "async-trait", + "axum-core", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + [[package]] name = "base64" version = "0.13.1" @@ -260,15 +512,15 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "3f1e31e207a6b8fb791a38ea3105e6cb541f55e4d029902d3039a4ad07cc4105" [[package]] name = "base64ct" -version = "1.5.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" @@ -283,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd9e32d7420c85055e8107e5b2463c4eeefeaac18b52359fe9f9c08a18f342b2" dependencies = [ "quote", - "syn", + "syn 1.0.109", ] [[package]] @@ -297,18 +549,48 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.10.3" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65" +dependencies = [ + "async-channel", + "async-lock", + "async-task", + "atomic-waker", + "fastrand", + "futures-lite", + "log", +] + [[package]] name = "bumpalo" -version = "3.12.0" +version = "3.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6ed94e98ecff0c12dd1b04c15ec0d7d9458ca8fe806cea6f12954efe74c63b" + +[[package]] +name = "bytemuck" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" [[package]] name = "byteorder" @@ -325,6 +607,47 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d3a8076e283f3acd27400535992edb3ba4b5bb72f8891ad8fbe7932a7d4b9" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesio" +version = "0.0.1" +dependencies = [ + "byteorder", + "bytes", + "common", + "futures", + "tokio", + "tokio-stream", + "tokio-util", +] + +[[package]] +name = "casey" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614586263949597dcc18675da12ef9b429135e13628d92eb8b8c6fa50ca5656b" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.0.79" @@ -339,9 +662,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.23" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", "num-integer", @@ -351,13 +674,13 @@ dependencies = [ ] [[package]] -name = "codespan-reporting" -version = "0.11.1" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "termcolor", - "unicode-width", + "crypto-common", + "inout", ] [[package]] @@ -365,20 +688,36 @@ name = "common" version = "0.1.0" dependencies = [ "anyhow", - "argon2", + "arc-swap", + "async-stream", "async-trait", - "bitmask-enum", - "chrono", "config", - "email_address", + "futures", + "http", + "lapin", "log", + "portpicker", + "prost", "serde", - "sqlx 0.6.2", "tempfile", "tokio", + "tokio-util", + "tonic", + "tonic-build", + "tower", "tracing", "tracing-log", "tracing-subscriber", + "trust-dns-resolver", +] + +[[package]] +name = "concurrent-queue" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -400,22 +739,18 @@ dependencies = [ "yaml-rust", ] -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - [[package]] name = "const-oid" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "520fbf3c07483f94e3e3ca9d0cfd913d7718ef2483d2cfd91c0d9e91474ab913" +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + [[package]] name = "core-foundation" version = "0.9.3" @@ -428,21 +763,15 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "coverage-helper" -version = "0.1.0" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9db510e477cf3c14a6c56aa2ed28e499f1e27e01c17723b41ece793d11cf3fe" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" dependencies = [ "libc", ] @@ -462,11 +791,17 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + [[package]] name = "crossbeam-channel" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" dependencies = [ "cfg-if", "crossbeam-utils", @@ -484,13 +819,19 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" +checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b" dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -501,55 +842,11 @@ dependencies = [ "typenum", ] -[[package]] -name = "cxx" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d3488e7665a7a483b57e25bdd90d0aeb2bc7608c8d0346acf2ad3f1caf1d62" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fcaf066a053a41a81dfb14d57d99738b767febb8b735c3016e469fac5da690" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ef98b8b717a829ca5603af80e1f9e2e48013ab227b68ef37872ef84ee479bf" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "086c685979a698443656e5cf7856c95c642295a38599f12fb1ff76fb28d19892" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "darling" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0808e1bd8671fb44a113a14e13497557533369847788fa2ae912b6ebfce9fa8" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" dependencies = [ "darling_core", "darling_macro", @@ -557,70 +854,78 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "001d80444f28e193f30c2f293455da62dcf9a6b29918a4253152ae2b1de592cb" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 1.0.109", ] [[package]] name = "darling_macro" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b36230598a2d5de7ec1c6f51f72d8a99a9208daff41de2084d06e3fd3ea56685" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ "darling_core", "quote", - "syn", + "syn 1.0.109", ] [[package]] -name = "der" -version = "0.6.1" +name = "dashmap" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cfg-if", + "hashbrown 0.12.3", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] -name = "digest" -version = "0.10.6" +name = "data-encoding" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" + +[[package]] +name = "der" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "block-buffer", "const-oid", - "crypto-common", - "subtle", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "dirs" -version = "4.0.0" +name = "des" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" dependencies = [ - "dirs-sys", + "cipher", ] [[package]] -name = "dirs-sys" -version = "0.3.7" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "libc", - "redox_users", - "winapi", + "block-buffer", + "const-oid", + "crypto-common", + "subtle", ] [[package]] @@ -629,22 +934,54 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dotenvy" -version = "0.15.6" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "edge" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", + "async-trait", + "bytes", + "chrono", "common", + "dotenvy", + "fred", + "futures", + "futures-util", "hyper", + "native-tls", + "nix", + "portpicker", + "prost", + "prost-build", + "routerify", "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-build", "tracing", + "url", + "url-parse", + "uuid", ] [[package]] @@ -674,6 +1011,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "env_logger" version = "0.7.1" @@ -687,12 +1036,61 @@ dependencies = [ "termcolor", ] +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "executor-trait" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1052dd43212a7777ec6a69b117da52f5e52f07aec47d00c1a2b33b85d06b08" +dependencies = [ + "async-trait", +] + +[[package]] +name = "exp_golomb" +version = "0.1.0" +dependencies = [ + "bytes", + "bytesio", +] + [[package]] name = "fast_chemail" version = "0.9.6" @@ -711,6 +1109,33 @@ dependencies = [ "instant", ] +[[package]] +name = "fixed" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79386fdcec5e0fde91b1a6a5bcd89677d1f9304f7f986b154a1b9109038854d9" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.10.14" @@ -720,7 +1145,23 @@ dependencies = [ "futures-core", "futures-sink", "pin-project", - "spin 0.9.5", + "spin 0.9.8", +] + +[[package]] +name = "flv" +version = "0.0.1" +dependencies = [ + "aac", + "amf0", + "av1", + "byteorder", + "bytes", + "bytesio", + "h264", + "h265", + "num-derive", + "num-traits", ] [[package]] @@ -753,11 +1194,41 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fred" +version = "6.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e02c21b098d77b0e99fe0054ebd3e7c9f81bffb42aa843021415ffa793124a6" +dependencies = [ + "arc-swap", + "arcstr", + "async-trait", + "bytes", + "bytes-utils", + "cfg-if", + "float-cmp", + "futures", + "lazy_static", + "log", + "native-tls", + "parking_lot", + "pretty_env_logger", + "rand", + "redis-protocol", + "semver", + "sha-1", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "futures" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" dependencies = [ "futures-channel", "futures-core", @@ -770,9 +1241,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" dependencies = [ "futures-core", "futures-sink", @@ -780,32 +1251,21 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" [[package]] name = "futures-executor" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" dependencies = [ "futures-core", "futures-task", "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot 0.11.2", -] - [[package]] name = "futures-intrusive" version = "0.5.0" @@ -814,37 +1274,52 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.12.1", + "parking_lot", ] [[package]] name = "futures-io" -version = "0.3.26" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-lite" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb8371b6fb2aeb2d280374607aeabfc99d95c72edfe51692e42d3d7f0d08531" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] [[package]] name = "futures-macro" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] name = "futures-sink" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" [[package]] name = "futures-task" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" [[package]] name = "futures-timer" @@ -854,9 +1329,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" dependencies = [ "futures-channel", "futures-core", @@ -872,9 +1347,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.6" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -882,20 +1357,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", "wasi", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "h2" -version = "0.3.15" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4" +checksum = "d357c7ae988e7d2182f7d7871d0b963962420b0678b0997ce7de72001aeab782" dependencies = [ "bytes", "fnv", @@ -910,11 +1391,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "h264" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "exp_golomb", +] + +[[package]] +name = "h265" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "exp_golomb", +] + +[[package]] +name = "half" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4af3693f1b705df946e9fe5631932443781d0aabb423b62fcd4d73f6d2fd0" +dependencies = [ + "crunchy", +] + [[package]] name = "handlebars" -version = "4.3.6" +version = "4.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "035ef95d03713f2c347a72547b7cd38cbc9af7cd51e6099fb62d586d4a6dee3a" +checksum = "83c3372087601b532857d332f5957cbae686da52bb7810bf038c3e3c3cc2fa0d" dependencies = [ "log", "pest", @@ -930,16 +1440,25 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.6", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", ] [[package]] name = "hashlink" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" +checksum = "0761a1b9491c4f2e3d66aa0f62d0fba0af9a0e2852e4d48ea506632a4b56e6aa" dependencies = [ - "hashbrown", + "hashbrown 0.13.2", ] [[package]] @@ -969,6 +1488,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + [[package]] name = "hex" version = "0.4.3" @@ -993,6 +1518,26 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "http" version = "0.2.9" @@ -1038,9 +1583,9 @@ dependencies = [ [[package]] name = "hyper" -version = "0.14.24" +version = "0.14.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c" +checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" dependencies = [ "bytes", "futures-channel", @@ -1060,6 +1605,18 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1075,9 +1632,9 @@ dependencies = [ [[package]] name = "hyper-tungstenite" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "880b8b1c98a5ec2a505c7c90db6d3f6f1f480af5655d9c5b55facc9382a5a5b5" +checksum = "226df6fd0aece319a325419d770aa9d947defa60463f142cd82b329121f906a3" dependencies = [ "hyper", "pin-project", @@ -1088,26 +1645,25 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.53" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "winapi", + "windows", ] [[package]] name = "iana-time-zone-haiku" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "cxx", - "cxx-build", + "cc", ] [[package]] @@ -1116,6 +1672,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.3.0" @@ -1128,12 +1695,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", "serde", ] @@ -1142,11 +1709,48 @@ name = "ingest" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", + "async-trait", + "bytes", + "bytesio", + "chrono", "common", + "dotenvy", + "flv", + "futures", + "futures-util", "hyper", + "lapin", + "mp4", + "native-tls", + "pnet", + "portpicker", + "prost", + "prost-build", + "rtmp", "serde", + "serde_json", + "serial_test", + "tempfile", "tokio", + "tokio-executor-trait", + "tokio-native-tls", + "tokio-reactor-trait", + "tonic", + "tonic-build", "tracing", + "transmuxer", + "uuid", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", ] [[package]] @@ -1158,11 +1762,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-lifetimes" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipconfig" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd302af1b90f2463a98fa5ad469fc212c8e3175a41c3068601bfa2727591c5be" +dependencies = [ + "socket2", + "widestring", + "winapi", + "winreg", +] + [[package]] name = "ipnet" -version = "2.7.1" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" + +[[package]] +name = "ipnetwork" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] [[package]] name = "itertools" @@ -1175,15 +1811,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" [[package]] name = "js-sys" -version = "0.3.61" +version = "0.3.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" dependencies = [ "wasm-bindgen", ] @@ -1214,6 +1850,28 @@ dependencies = [ "sha2", ] +[[package]] +name = "lapin" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc13beaa09eed710f406201f46b961345b4d061dd90ec3d3ccc70721e70342a" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume", + "futures-core", + "futures-io", + "parking_lot", + "pinky-swear", + "reactor-trait", + "serde", + "tracing", + "waker-fn", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1225,21 +1883,21 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.139" +version = "0.2.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" [[package]] name = "libm" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "libsqlite3-sys" -version = "0.25.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" dependencies = [ "cc", "pkg-config", @@ -1247,19 +1905,16 @@ dependencies = [ ] [[package]] -name = "link-cplusplus" -version = "1.0.8" +name = "linked-hash-map" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "linux-raw-sys" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "lock_api" @@ -1286,9 +1941,24 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" dependencies = [ - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", ] +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "matchers" version = "0.1.0" @@ -1298,6 +1968,18 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" + [[package]] name = "md-5" version = "0.10.5" @@ -1313,11 +1995,20 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minimal-lexical" @@ -1337,11 +2028,29 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "mp4" +version = "0.0.1" +dependencies = [ + "aac", + "av1", + "byteorder", + "bytes", + "bytesio", + "casey", + "fixed", + "h264", + "h265", + "paste", + "serde", + "serde_json", +] + [[package]] name = "multer" -version = "2.0.4" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed4198ce7a4cbd2a57af78d28c6fbb57d81ac5f1d6ad79ac6c5587419cbdf22" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" dependencies = [ "bytes", "encoding_rs", @@ -1351,10 +2060,16 @@ dependencies = [ "log", "memchr", "mime", - "spin 0.9.5", + "spin 0.9.8", "version_check", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "native-tls" version = "0.2.11" @@ -1375,32 +2090,43 @@ dependencies = [ [[package]] name = "negative-impl" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a664fa920a46ed3e9ee16c66611ce1c9eb2ef4d8cb16ea09f65e9e7f6d388834" +checksum = "5bc49c1f9bc54ae6e2fce68b0b521fe7d685267f3e618217feb55976a24ba058" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] -name = "nom" -version = "7.1.3" +name = "nix" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a" dependencies = [ - "memchr", - "minimal-lexical", + "bitflags", + "cfg-if", + "libc", + "memoffset", + "pin-utils", + "static_assertions", ] [[package]] -name = "nom8" -version = "0.2.0" +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + +[[package]] +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", + "minimal-lexical", ] [[package]] @@ -1430,6 +2156,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1479,9 +2216,9 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "openssl" -version = "0.10.48" +version = "0.10.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518915b97df115dd36109bfa429a48b8f737bd05508cf9588977b599648926d2" +checksum = "01b8574602df80f7b85fdfc5392fa884a4e3b3f4f35402c070ab34c3d3f78d56" dependencies = [ "bitflags", "cfg-if", @@ -1494,13 +2231,13 @@ dependencies = [ [[package]] name = "openssl-macros" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] @@ -1511,11 +2248,10 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.83" +version = "0.9.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666416d899cf077260dac8698d60a60b435a46d57e82acb1be3d0dad87284e5b" +checksum = "8e17f59264b2809d77ae94f0e1ebabc434773f370d6ca667bd223ea10e06cc7e" dependencies = [ - "autocfg", "cc", "libc", "pkg-config", @@ -1572,7 +2308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" dependencies = [ "dlv-list", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -1582,16 +2318,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] -name = "parking_lot" -version = "0.11.2" +name = "p12" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224" dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", + "cbc", + "cipher", + "des", + "getrandom", + "hmac", + "lazy_static", + "rc2", + "sha1", + "yasna", ] +[[package]] +name = "parking" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e" + [[package]] name = "parking_lot" version = "0.12.1" @@ -1599,21 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", - "parking_lot_core 0.9.7", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -1624,16 +2358,16 @@ checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "smallvec", "windows-sys 0.45.0", ] [[package]] name = "password-hash" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core", @@ -1642,9 +2376,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01a5bd0424d00070b0098dd17ebca6f961a959dead1dbcbbbc1d1cd8d3deeba" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" [[package]] name = "pathdiff" @@ -1669,9 +2403,9 @@ checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" [[package]] name = "pest" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028accff104c4e513bad663bbcd2ad7cfd5304144404c31ed0a77ac103d00660" +checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70" dependencies = [ "thiserror", "ucd-trie", @@ -1679,9 +2413,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac3922aac69a40733080f53c1ce7f91dcf57e1a5f6c52f421fadec7fbdc4b69" +checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb" dependencies = [ "pest", "pest_generator", @@ -1689,46 +2423,56 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06646e185566b5961b4058dd107e0a7f56e77c3f484549fb119867773c0f202" +checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] name = "pest_meta" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f60b2ba541577e2a0c307c8f39d1439108120eb7903adeb6497fa880c59616" +checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411" dependencies = [ "once_cell", "pest", "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project" -version = "1.0.12" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +checksum = "c95a7476719eab1e366eaf73d0260af3021184f18177925b07f54b30089ceead" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.0.12" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] @@ -1743,6 +2487,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinky-swear" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d894b67aa7a4bf295db5e85349078c604edaa6fa5c8721e8eca3c7729a27f2ac" +dependencies = [ + "doc-comment", + "flume", + "parking_lot", + "tracing", +] + [[package]] name = "pkcs1" version = "0.4.1" @@ -1767,20 +2523,124 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" [[package]] -name = "player" -version = "0.1.0" +name = "pnet" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd959a8268165518e2bf5546ba84c7b3222744435616381df3c456fe8d983576" dependencies = [ - "console_error_panic_hook", - "time", - "tracing", - "tracing-subscriber", - "tracing-web", - "wasm-bindgen", + "ipnetwork", + "pnet_base", + "pnet_datalink", + "pnet_packet", + "pnet_sys", + "pnet_transport", +] + +[[package]] +name = "pnet_base" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872e46346144ebf35219ccaa64b1dffacd9c6f188cd7d012bd6977a2a838f42e" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c302da22118d2793c312a35fb3da6846cb0fab6c3ad53fd67e37809b06cdafce" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_macros" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a780e80005c2e463ec25a6e9f928630049a10b43945fea83207207d4a7606f4" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", +] + +[[package]] +name = "pnet_macros_support" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d932134f32efd7834eb8b16d42418dac87086347d1bc7d142370ef078582bc" +dependencies = [ + "pnet_base", +] + +[[package]] +name = "pnet_packet" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde678bbd85cb1c2d99dc9fc596e57f03aa725f84f3168b0eaf33eeccb41706" +dependencies = [ + "glob", + "pnet_base", + "pnet_macros", + "pnet_macros_support", +] + +[[package]] +name = "pnet_sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf7a58b2803d818a374be9278a1fe8f88fce14b936afbe225000cfcd9c73f16" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pnet_transport" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813d1c0e4defbe7ee22f6fe1755f122b77bfb5abe77145b1b5baaf463cab9249" +dependencies = [ + "libc", + "pnet_base", + "pnet_packet", + "pnet_sys", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "portpicker" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" +dependencies = [ + "rand", ] [[package]] @@ -1789,11 +2649,31 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "prettyplease" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "proc-macro-crate" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66618389e4ec1c7afe67d51a9bf34ff9236480f8d51e7489b7d5ab0303c13f34" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", "toml_edit", @@ -1801,13 +2681,67 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" +checksum = "fa1fb82fc0c281dd9671101b66b771ebbe1eaf967b96ac8740dcba4b70005ca8" dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck", + "itertools", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1816,9 +2750,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.23" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" dependencies = [ "proc-macro2", ] @@ -1829,28 +2763,62 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "libc", - "rand_chacha", - "rand_core", + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "reactor-trait" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" dependencies = [ - "ppv-lite86", - "rand_core", + "async-trait", + "futures-core", + "futures-io", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "redis-protocol" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "9c31deddf734dc0a39d3112e73490e88b61a05e83e074d211f348404cee4d2c6" dependencies = [ - "getrandom", + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom", ] [[package]] @@ -1863,25 +2831,23 @@ dependencies = [ ] [[package]] -name = "redox_users" -version = "0.4.3" +name = "redox_syscall" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "getrandom", - "redox_syscall", - "thiserror", + "bitflags", ] [[package]] name = "regex" -version = "1.7.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.7.1", ] [[package]] @@ -1890,31 +2856,28 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" dependencies = [ - "regex-syntax", + "regex-syntax 0.6.29", ] [[package]] name = "regex-syntax" -version = "0.6.28" +version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "regex-syntax" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" [[package]] name = "reqwest" -version = "0.11.14" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21eed90ec8570952d53b772ecf8f206aa1ec9a3d76b2521c56c42973f2d91ee9" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" dependencies = [ - "base64 0.21.0", + "base64 0.21.1", "bytes", "encoding_rs", "futures-core", @@ -1945,6 +2908,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error", +] + [[package]] name = "ring" version = "0.16.20" @@ -2004,6 +2977,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtmp" +version = "0.0.1" +dependencies = [ + "amf0", + "async-trait", + "byteorder", + "bytes", + "bytesio", + "chrono", + "common", + "flv", + "futures", + "h264", + "hmac", + "num-derive", + "num-traits", + "rand", + "serde_json", + "sha2", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -2014,16 +3012,54 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustix" +version = "0.37.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rustls" -version = "0.20.8" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" +checksum = "c911ba11bc8433e811ce56fde130ccf32f5127cab0e0194e9c68c5a5b671791e" dependencies = [ "log", "ring", + "rustls-webpki", "sct", - "webpki", +] + +[[package]] +name = "rustls-connector" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c8d6cf0e464eff7cee6ba0419f56a65d29999fc164dd719c8633fbb401365f" +dependencies = [ + "log", + "rustls", + "rustls-native-certs", + "rustls-webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", ] [[package]] @@ -2032,14 +3068,30 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" dependencies = [ - "base64 0.21.0", + "base64 0.21.1", ] [[package]] -name = "ryu" +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" [[package]] name = "schannel" @@ -2056,12 +3108,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "scratch" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" - [[package]] name = "sct" version = "0.7.0" @@ -2074,9 +3120,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.8.2" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" dependencies = [ "bitflags", "core-foundation", @@ -2087,39 +3133,45 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + [[package]] name = "serde" -version = "1.0.152" +version = "1.0.163" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" +checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.152" +version = "1.0.163" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" +checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] name = "serde_json" -version = "1.0.93" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" dependencies = [ "itoa", "ryu", @@ -2138,6 +3190,42 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +dependencies = [ + "dashmap", + "futures", + "lazy_static", + "log", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha1" version = "0.10.5" @@ -2180,9 +3268,9 @@ dependencies = [ [[package]] name = "signature" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe458c98333f9c8152221191a77e2a44e8325d0193484af2e9421a53019e57d" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" dependencies = [ "digest", "rand_core", @@ -2190,9 +3278,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" dependencies = [ "autocfg", ] @@ -2205,9 +3293,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "socket2" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" dependencies = [ "libc", "winapi", @@ -2221,9 +3309,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.5" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dccf47db1b41fa1573ed27ccf5e08e3ca771cb994f776668c5ebda893b248fc" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -2251,21 +3339,11 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9249290c05928352f71c077cc44a464d880c63f26f7534728cca008e135c0428" -dependencies = [ - "sqlx-core 0.6.2", - "sqlx-macros 0.6.2", -] - -[[package]] -name = "sqlx" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ - "sqlx-core 0.7.0-alpha.2", - "sqlx-macros 0.7.0-alpha.2", + "sqlx-core", + "sqlx-macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", @@ -2273,64 +3351,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105" -dependencies = [ - "ahash", - "atoi 1.0.0", - "base64 0.13.1", - "bitflags", - "byteorder", - "bytes", - "crc", - "crossbeam-queue", - "dirs", - "dotenvy", - "either", - "event-listener", - "futures-channel", - "futures-core", - "futures-intrusive 0.4.2", - "futures-util", - "hashlink", - "hex", - "hkdf", - "hmac", - "indexmap", - "itoa", - "libc", - "log", - "md-5", - "memchr", - "once_cell", - "paste", - "percent-encoding", - "rand", - "rustls", - "rustls-pemfile", - "serde", - "serde_json", - "sha1", - "sha2", - "smallvec", - "sqlformat", - "sqlx-rt", - "stringprep", - "thiserror", - "tokio-stream", - "url", - "webpki-roots", - "whoami", -] - -[[package]] -name = "sqlx-core" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ - "ahash", - "atoi 2.0.0", + "ahash 0.7.6", + "atoi", "bitflags", "byteorder", "bytes", @@ -2342,7 +3367,7 @@ dependencies = [ "event-listener", "futures-channel", "futures-core", - "futures-intrusive 0.5.0", + "futures-intrusive", "futures-io", "futures-util", "hashlink", @@ -2364,46 +3389,25 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] name = "sqlx-macros" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b850fa514dc11f2ee85be9d055c512aa866746adfacd1cb42d867d68e6a5b0d9" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core 0.6.2", - "sqlx-rt", - "syn", - "url", -] - -[[package]] -name = "sqlx-macros" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ "proc-macro2", "quote", - "sqlx-core 0.7.0-alpha.2", + "sqlx-core", "sqlx-macros-core", - "syn", + "syn 1.0.109", ] [[package]] name = "sqlx-macros-core" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ "dotenvy", "either", @@ -2415,11 +3419,11 @@ dependencies = [ "serde", "serde_json", "sha2", - "sqlx-core 0.7.0-alpha.2", + "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 1.0.109", "tempfile", "tokio", "url", @@ -2427,18 +3431,17 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ - "atoi 2.0.0", - "base64 0.21.0", + "atoi", + "base64 0.21.1", "bitflags", "byteorder", "bytes", "chrono", "crc", "digest", - "dirs", "dotenvy", "either", "futures-channel", @@ -2461,26 +3464,27 @@ dependencies = [ "sha1", "sha2", "smallvec", - "sqlx-core 0.7.0-alpha.2", + "sqlx-core", "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ - "atoi 2.0.0", - "base64 0.21.0", + "atoi", + "base64 0.21.1", "bitflags", "byteorder", "chrono", "crc", - "dirs", "dotenvy", + "etcetera", "futures-channel", "futures-core", "futures-io", @@ -2488,6 +3492,7 @@ dependencies = [ "hex", "hkdf", "hmac", + "home", "itoa", "log", "md-5", @@ -2499,45 +3504,36 @@ dependencies = [ "sha1", "sha2", "smallvec", - "sqlx-core 0.7.0-alpha.2", + "sqlx-core", "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] -[[package]] -name = "sqlx-rt" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24c5b2d25fa654cc5f841750b8e1cdedbe21189bf9a9382ee90bfa9dd3562396" -dependencies = [ - "once_cell", - "tokio", - "tokio-rustls", -] - [[package]] name = "sqlx-sqlite" -version = "0.7.0-alpha.2" -source = "git+https://github.com/launchbadge/sqlx?branch=main#14d70feab1c7e3bc7028ea874bfb993406ac78bc" +version = "0.7.0-alpha.3" +source = "git+https://github.com/launchbadge/sqlx?branch=main#253d8c9f696a3a2c7aa837b04cc93605a1376694" dependencies = [ - "atoi 2.0.0", + "atoi", "bitflags", "chrono", "flume", "futures-channel", "futures-core", "futures-executor", - "futures-intrusive 0.5.0", + "futures-intrusive", "futures-util", "libsqlite3-sys", "log", "percent-encoding", "serde", - "sqlx-core 0.7.0-alpha.2", + "sqlx-core", "tracing", "url", + "uuid", ] [[package]] @@ -2564,15 +3560,15 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" -version = "1.0.107" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", @@ -2580,84 +3576,85 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.3.0" +name = "syn" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" dependencies = [ - "cfg-if", - "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "termcolor" -version = "1.2.0" +name = "sync_wrapper" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" -dependencies = [ - "winapi-util", -] +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] -name = "thiserror" -version = "1.0.38" +name = "tcp-stream" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +checksum = "1322b18a9e329ba45e4430b19543045b85cd1dcb2892e77d27ab471ba2039bd1" dependencies = [ - "thiserror-impl", + "cfg-if", + "native-tls", + "p12", + "rustls-connector", + "rustls-pemfile", ] [[package]] -name = "thiserror-impl" -version = "1.0.38" +name = "tempfile" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", + "fastrand", + "redox_syscall 0.3.5", + "rustix", + "windows-sys 0.45.0", ] [[package]] -name = "thread_local" -version = "1.1.7" +name = "termcolor" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" dependencies = [ - "cfg-if", - "once_cell", + "winapi-util", ] [[package]] -name = "time" -version = "0.3.19" +name = "thiserror" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53250a3b3fed8ff8fd988587d8925d26a83ac3845d9e03b220b37f34c2b8d6c2" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" dependencies = [ - "itoa", - "js-sys", - "serde", - "time-core", - "time-macros", + "thiserror-impl", ] [[package]] -name = "time-core" -version = "0.1.0" +name = "thiserror-impl" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] [[package]] -name = "time-macros" -version = "0.2.7" +name = "thread_local" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a460aeb8de6dcb0f381e1ee05f1cd56fcf5a5f6eb8187ff3d8f0b11078d38b7c" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" dependencies = [ - "time-core", + "cfg-if", + "once_cell", ] [[package]] @@ -2677,33 +3674,53 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.25.0" +version = "1.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e00990ebabbe4c14c08aca901caed183ecd5c09562a12c824bb53d3c3fd3af" +checksum = "0aa32867d44e6f2ce3385e89dceb990188b8bb0fb25b0cf576647a6f98ac5105" dependencies = [ "autocfg", "bytes", "libc", - "memchr", "mio", "num_cpus", - "parking_lot 0.12.1", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.42.0", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-executor-trait" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802ccf58e108fe16561f35348fabe15ff38218968f033d587e399a84937533cc" +dependencies = [ + "async-trait", + "executor-trait", + "tokio", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", ] [[package]] name = "tokio-macros" -version = "1.8.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] @@ -2716,22 +3733,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9481a72f36bd9cbb8d6dd349227c4783e234e4332cfe806225bc929c4b92486" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", + "reactor-trait", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-rustls" -version = "0.23.4" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" dependencies = [ "rustls", "tokio", - "webpki", ] [[package]] name = "tokio-stream" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ "futures-core", "pin-project-lite", @@ -2740,9 +3770,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd" +checksum = "ec509ac96e9a0c43427c74f003127d953a265737636129424288d27cb5c4b12c" dependencies = [ "futures-util", "log", @@ -2752,9 +3782,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" dependencies = [ "bytes", "futures-core", @@ -2775,21 +3805,91 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.5.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4553f467ac8e3d374bc9a177a26801e5d0f9b211aa1673fb137a403afd1c9cf5" +checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f" [[package]] name = "toml_edit" -version = "0.18.1" +version = "0.19.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b" +checksum = "92d964908cec0d030b812013af25a0e57fddfadb1e066ecc6681d86253129d4f" dependencies = [ "indexmap", - "nom8", "toml_datetime", + "winnow", +] + +[[package]] +name = "tonic" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", ] +[[package]] +name = "tonic-build" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -2811,20 +3911,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" +checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", ] [[package]] name = "tracing-core" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", "valuable", @@ -2866,9 +3966,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ "matchers", "nu-ansi-term", @@ -2879,36 +3979,110 @@ dependencies = [ "sharded-slab", "smallvec", "thread_local", - "time", "tracing", "tracing-core", "tracing-log", "tracing-serde", ] -[[package]] -name = "tracing-web" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ff5efc53ed5a7c4b99b3dd24fd10f41e7aa1b284a4e64ae9167d97e31afe124" -dependencies = [ - "js-sys", - "tracing-core", - "tracing-subscriber", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "transcoder" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", + "async-trait", + "bytes", + "bytesio", + "chrono", "common", + "dotenvy", + "fred", + "futures", + "futures-util", "hyper", + "lapin", + "mp4", + "native-tls", + "nix", + "portpicker", + "prost", + "prost-build", + "serde", + "serial_test", + "sha2", + "tempfile", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-build", + "tracing", + "url-parse", +] + +[[package]] +name = "transmuxer" +version = "0.0.1" +dependencies = [ + "aac", + "amf0", + "av1", + "byteorder", + "bytes", + "bytesio", + "flv", + "h264", + "h265", + "mp4", "serde", + "serde_json", +] + +[[package]] +name = "trust-dns-proto" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.2.3", + "ipnet", + "lazy_static", + "rand", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aff21aa4dcefb0a1afbfac26deb0adc93888c7d295fb63ab273ef276ba2b7cfe" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lazy_static", + "lru-cache", + "parking_lot", + "resolv-conf", + "smallvec", + "thiserror", "tokio", "tracing", + "trust-dns-proto", ] [[package]] @@ -2919,13 +4093,13 @@ checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" [[package]] name = "tungstenite" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788" +checksum = "15fba1a6d6bb030745759a9a2a588bfe8490fc8b4751a277db3a0be1c9ebbf67" dependencies = [ - "base64 0.13.1", "byteorder", "bytes", + "data-encoding", "http", "httparse", "log", @@ -2950,15 +4124,15 @@ checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" [[package]] name = "unicode-bidi" -version = "0.3.10" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54675592c1dbefd78cbd98db9bacd89886e1ca50692a0692baefffdeb92dd58" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" [[package]] name = "unicode-ident" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" [[package]] name = "unicode-normalization" @@ -2975,12 +4149,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -3000,16 +4168,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", - "idna", + "idna 0.3.0", "percent-encoding", ] +[[package]] +name = "url-parse" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b316bdc240d73d26f84d3b0779b89a8fddc9f0b3aa54cf4979b74dda4e08b2" +dependencies = [ + "regex", +] + [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "uuid" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2" +dependencies = [ + "getrandom", + "serde", +] + [[package]] name = "valuable" version = "0.1.0" @@ -3028,6 +4215,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + [[package]] name = "want" version = "0.3.0" @@ -3046,9 +4239,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.84" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3056,24 +4249,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.84" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" +checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.16", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.34" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454" +checksum = "2d1985d03709c53167ce907ff394f5316aa22cb4e12761295c5dc57dacb6297e" dependencies = [ "cfg-if", "js-sys", @@ -3083,9 +4276,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.84" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" +checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3093,61 +4286,55 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.84" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" +checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.16", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.84" +version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" +checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" [[package]] name = "web-sys" -version = "0.3.61" +version = "0.3.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" +checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webpki" -version = "0.22.0" +name = "which" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" dependencies = [ - "ring", - "untrusted", + "either", + "libc", + "once_cell", ] [[package]] -name = "webpki-roots" -version = "0.22.6" +name = "whoami" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] +checksum = "2c70234412ca409cc04e864e89523cb0fc37f5e1344ebed5a3ebf4192b6b9f68" [[package]] -name = "whoami" -version = "1.3.0" +name = "widestring" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45dbc71f0cdca27dc261a9bd37ddec174e4a0af2b900b890f378460f745426e3" -dependencies = [ - "wasm-bindgen", - "web-sys", -] +checksum = "17882f045410753661207383517a6f62ec3dbeb6a4ed2acce01f0728238d1983" [[package]] name = "winapi" @@ -3180,19 +4367,28 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + [[package]] name = "windows-sys" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3201,65 +4397,140 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] name = "windows-targets" -version = "0.42.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.1" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" -version = "0.42.1" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "winnow" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" +checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -3279,8 +4550,14 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" + [[package]] name = "zeroize" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" diff --git a/Cargo.toml b/Cargo.toml index 35803d69..15e77663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,21 +2,23 @@ members = [ "backend/api", - "frontend/player", "video/edge", "video/ingest", "video/transcoder", + "video/bytesio", + "video/container/flv", + "video/container/mp4", + "video/codec/h264", + "video/codec/h265", + "video/codec/av1", + "video/codec/aac", + "video/protocol/rtmp", + "video/transmuxer", + "video/utils/amf0", + "video/utils/exp_golomb", "common", ] -# We don't want to build the wasm by default, this is because its built by a yarn script -default-members = [ - "backend/api", - "video/edge", - "video/ingest", - "video/transcoder", - "common", +exclude = [ + "frontend/player", ] - -[profile.release.package.player] -opt-level = "s" diff --git a/backend/api/Cargo.toml b/backend/api/Cargo.toml index b787bb7e..96bb3d12 100644 --- a/backend/api/Cargo.toml +++ b/backend/api/Cargo.toml @@ -19,32 +19,43 @@ test = false bench = false [dependencies] -anyhow = "1.0.68" -tracing = "0.1.37" -tokio = { version = "1.25.0", features = ["full"] } -serde = { version = "1.0.152", features = ["derive"] } -hyper = { version = "0.14.24", features = ["full"] } +anyhow = "1" +tracing = "0" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +hyper = { version = "0", features = ["full"] } common = { path = "../../common" } -sqlx = { git="https://github.com/launchbadge/sqlx", branch="main", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono"] } -routerify = "3.0.0" -serde_json = "1.0.93" -reqwest = { version = "0.11.14", features = ["json"] } -chrono = { version = "0.4.23", default-features = false, features = ["serde"] } -async-graphql = { version = "5.0.6", features = ["apollo_tracing", "apollo_persisted_queries", "tracing", "opentelemetry", "dataloader", "string_number"] } -hyper-tungstenite = "0.9.0" -async-stream = "0.3.4" -futures = "0.3.26" -futures-util = "0.3.26" -thiserror = "1.0.38" -arc-swap = "1.6.0" -jwt = "0.16.0" -hmac = "0.12.1" -sha2 = "0.10.6" -negative-impl = "0.1.2" +sqlx = { git="https://github.com/launchbadge/sqlx", branch="main", features = ["postgres", "runtime-tokio-native-tls", "json", "chrono", "uuid"] } +routerify = "3" +serde_json = "1" +reqwest = { version = "0", features = ["json"] } +chrono = { version = "0", default-features = false, features = ["serde", "clock"] } +async-graphql = { version = "5", features = ["apollo_tracing", "apollo_persisted_queries", "tracing", "opentelemetry", "dataloader", "string_number", "uuid"] } +hyper-tungstenite = "0" +async-stream = "0" +futures = "0" +futures-util = "0" +arc-swap = "1" +jwt = "0" +hmac = "0" +sha2 = "0" +negative-impl = "0" +tonic = { version = "0", features = ["tls"] } +prost = "0" +uuid = "1" +bitmask-enum = "2" +argon2 = "0" +email_address = "0" +rand = "0" +lapin = { version = "2.0.3", features = ["native-tls"] } [dev-dependencies] -tempfile = "3.3.0" -dotenvy = "0.15.6" -http = "0.2.9" -tokio-tungstenite = "0.18.0" -coverage-helper = "0.1" +tempfile = "3" +dotenvy = "0" +http = "0" +tokio-tungstenite = "0" +portpicker = "0" +serial_test = "2" + +[build-dependencies] +tonic-build = "0" diff --git a/backend/api/build.rs b/backend/api/build.rs new file mode 100644 index 00000000..1e75f760 --- /dev/null +++ b/backend/api/build.rs @@ -0,0 +1,14 @@ +const PROTO_DIR: &str = "../../proto"; + +fn main() { + tonic_build::configure() + .compile( + &[ + format!("{}/scuffle/events/ingest.proto", PROTO_DIR), + format!("{}/scuffle/backend/api.proto", PROTO_DIR), + format!("{}/scuffle/utils/health.proto", PROTO_DIR), + ], + &[PROTO_DIR], + ) + .unwrap(); +} diff --git a/backend/api/src/api/middleware/auth.rs b/backend/api/src/api/middleware/auth.rs index 64051c45..d2ece6f3 100644 --- a/backend/api/src/api/middleware/auth.rs +++ b/backend/api/src/api/middleware/auth.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use common::types::session; use hyper::http::header; use hyper::{header::HeaderValue, Body, StatusCode}; use routerify::{prelude::RequestExt, Middleware}; @@ -109,18 +108,15 @@ pub fn auth_middleware(_: &Arc) -> Middleware { fail_fast!(mode, req); } - req.set_response_header(X_AUTH_TOKEN_CHECK_STATUS, AuthTokenCheckStatus::Success); - req.set_context(session); - - Ok(req) - }) -} + let permissions = global + .user_permisions_by_id_loader + .load_one(session.user_id) + .await + .map_err_route("failed to fetch user permissions")? + .unwrap_or_default(); -pub fn auth_required() -> Middleware { - Middleware::pre(|req| async move { - if req.context::().is_none() { - return Err(RouteError::from((StatusCode::UNAUTHORIZED, "unauthorized"))); - } + req.set_response_header(X_AUTH_TOKEN_CHECK_STATUS, AuthTokenCheckStatus::Success); + req.set_context((session, permissions)); Ok(req) }) diff --git a/backend/api/src/api/mod.rs b/backend/api/src/api/mod.rs index d9172ca7..409fa7ab 100644 --- a/backend/api/src/api/mod.rs +++ b/backend/api/src/api/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use hyper::{server::conn::Http, Body, Response, StatusCode}; use routerify::{RequestInfo, RequestServiceBuilder, Router}; use serde_json::json; -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use tokio::{net::TcpSocket, select}; use crate::{api::macros::make_response, global::GlobalState}; @@ -65,10 +65,8 @@ pub fn routes(global: &Arc) -> Router { } pub async fn run(global: Arc) -> Result<()> { - let addr: SocketAddr = global.config.bind_address.parse()?; - - tracing::info!("Listening on {}", addr); - let socket = if addr.is_ipv6() { + tracing::info!("Listening on {}", global.config.api.bind_address); + let socket = if global.config.api.bind_address.is_ipv6() { TcpSocket::new_v6()? } else { TcpSocket::new_v4()? @@ -76,7 +74,7 @@ pub async fn run(global: Arc) -> Result<()> { socket.set_reuseaddr(true)?; socket.set_reuseport(true)?; - socket.bind(addr)?; + socket.bind(global.config.api.bind_address)?; let listener = socket.listen(1024)?; // The reason we use a Weak reference to the global state is because we don't want to block the shutdown diff --git a/backend/api/src/api/v1/gql/auth.rs b/backend/api/src/api/v1/gql/auth.rs index ff14c456..47a66846 100644 --- a/backend/api/src/api/v1/gql/auth.rs +++ b/backend/api/src/api/v1/gql/auth.rs @@ -2,9 +2,9 @@ use super::error::{GqlError, Result, ResultExt}; use super::ext::ContextExt; use super::models::session::Session; use crate::api::v1::jwt::JwtState; +use crate::database::{session, user}; use async_graphql::{Context, Object}; use chrono::{Duration, Utc}; -use common::types::{session, user}; #[derive(Default, Clone)] pub struct AuthMutation; @@ -78,9 +78,16 @@ impl AuthMutation { .serialize(global) .ok_or((GqlError::InternalServerError, "Failed to serialize JWT"))?; + let permissions = global + .user_permisions_by_id_loader + .load_one(user.id) + .await + .map_err_gql("Failed to fetch user permissions")? + .unwrap_or_default(); + // We need to update the request context with the new session if update_context.unwrap_or(true) { - request_context.set_session(Some(session.clone())); + request_context.set_session(Some((session.clone(), permissions))); } Ok(Session { @@ -132,9 +139,16 @@ impl AuthMutation { return Err(GqlError::InvalidSession.with_message("Session token is no longer valid")); } + let permissions = global + .user_permisions_by_id_loader + .load_one(session.user_id) + .await + .map_err_gql("Failed to fetch user permissions")? + .unwrap_or_default(); + // We need to update the request context with the new session if update_context.unwrap_or(true) { - request_context.set_session(Some(session.clone())); + request_context.set_session(Some((session.clone(), permissions))); } Ok(Session { @@ -176,6 +190,7 @@ impl AuthMutation { .with_field(vec!["captchaToken"])); } + let display_name = username.clone(); let username = username.to_lowercase(); let email = email.to_lowercase(); @@ -216,10 +231,12 @@ impl AuthMutation { // TODO: maybe look to batch this let user = sqlx::query_as!(user::Model, - "INSERT INTO users (username, password_hash, email) VALUES ($1, $2, $3) RETURNING *", + "INSERT INTO users (username, display_name, password_hash, email, stream_key) VALUES ($1, $2, $3, $4, $5) RETURNING *", username, + display_name, user::hash_password(&password), email, + user::generate_stream_key(), ) .fetch_one(&mut *tx) .await @@ -249,9 +266,16 @@ impl AuthMutation { .await .map_err_gql("Failed to commit transaction")?; + let permissions = global + .user_permisions_by_id_loader + .load_one(user.id) + .await + .map_err_gql("Failed to fetch user permissions")? + .unwrap_or_default(); + // We need to update the request context with the new session if update_context.unwrap_or(true) { - request_context.set_session(Some(session.clone())); + request_context.set_session(Some((session.clone(), permissions))); } Ok(Session { @@ -287,7 +311,7 @@ impl AuthMutation { let session_id = match ( Option::as_ref(&jwt).map(|jwt| jwt.session_id), - Option::as_ref(&session).map(|s| s.id), + Option::as_ref(&session).map(|(s, _)| s.id), ) { (Some(id), _) => id, (None, Some(id)) => id, diff --git a/backend/api/src/api/v1/gql/handlers.rs b/backend/api/src/api/v1/gql/handlers.rs index b59fcc58..4b8fe2a3 100644 --- a/backend/api/src/api/v1/gql/handlers.rs +++ b/backend/api/src/api/v1/gql/handlers.rs @@ -1,10 +1,10 @@ use std::{future, str::FromStr, sync::Arc}; +use crate::database::session; use async_graphql::{ http::{WebSocketProtocols, WsMessage}, Data, ErrorExtensions, }; -use common::types::session; use futures_util::{SinkExt, StreamExt}; use hyper::{body::HttpBody, header, Body, Request, Response, StatusCode}; use hyper_tungstenite::{ @@ -24,6 +24,7 @@ use crate::{ ext::RequestExt as _, v1::jwt::JwtState, }, + dataloader::user_permissions::UserPermission, global::GlobalState, }; @@ -92,7 +93,13 @@ async fn websocket_handler( return Err(GqlError::InvalidSession.with_message("session has invalidated").extend()); } - request_context.set_session(Some(session)); + let permissions = global + .user_permisions_by_id_loader + .load_one(session.id) + .await + .map_err_gql("failed to fetch permissions")?.unwrap_or_default(); + + request_context.set_session(Some((session, permissions))); } Ok(data) @@ -139,7 +146,7 @@ pub async fn graphql_handler(mut req: Request) -> Result> { let global = req.get_global()?; - let session = req.context::(); + let session = req.context::<(session::Model, UserPermission)>(); // We need to check if this is a websocket upgrade request. // If it is, we need to upgrade the request to a websocket request. diff --git a/backend/api/src/api/v1/gql/mod.rs b/backend/api/src/api/v1/gql/mod.rs index 60e92ce6..e23624de 100644 --- a/backend/api/src/api/v1/gql/mod.rs +++ b/backend/api/src/api/v1/gql/mod.rs @@ -5,6 +5,7 @@ use async_graphql::{ }; use hyper::{Body, Response}; use routerify::Router; +use uuid::Uuid; use crate::{api::error::RouteError, global::GlobalState}; @@ -61,6 +62,22 @@ impl Query { Ok(user.map(models::user::User::from)) } + + async fn user_by_id( + &self, + ctx: &Context<'_>, + #[graphql(desc = "The id of the user.")] id: Uuid, + ) -> Result> { + let global = ctx.get_global(); + + let user = global + .user_by_id_loader + .load_one(id) + .await + .map_err_gql("failed to fetch user")?; + + Ok(user.map(models::user::User::from)) + } } pub type MySchema = Schema; @@ -75,7 +92,6 @@ pub fn schema() -> MySchema { ) .enable_federation() .enable_subscription_in_federation() - .extension(extensions::ApolloTracing) .extension(extensions::Analyzer) .limit_complexity(100) // We don't want to allow too complex queries to be executed .finish() diff --git a/backend/api/src/api/v1/gql/models/global_roles.rs b/backend/api/src/api/v1/gql/models/global_roles.rs new file mode 100644 index 00000000..66b5fec7 --- /dev/null +++ b/backend/api/src/api/v1/gql/models/global_roles.rs @@ -0,0 +1,31 @@ +use async_graphql::SimpleObject; + +use crate::database::global_role; +use uuid::Uuid; + +use super::date::DateRFC3339; + +#[derive(SimpleObject, Clone)] +pub struct GlobalRole { + pub id: Uuid, + pub name: String, + pub description: String, + pub rank: i32, + pub allowed_permissions: i64, + pub denied_permissions: i64, + pub created_at: DateRFC3339, +} + +impl From for GlobalRole { + fn from(value: global_role::Model) -> Self { + Self { + id: value.id, + created_at: value.created_at.into(), + name: value.name, + description: value.description, + rank: value.rank as i32, + allowed_permissions: value.allowed_permissions.bits(), + denied_permissions: value.denied_permissions.bits(), + } + } +} diff --git a/backend/api/src/api/v1/gql/models/mod.rs b/backend/api/src/api/v1/gql/models/mod.rs index 21e92131..e3277a59 100644 --- a/backend/api/src/api/v1/gql/models/mod.rs +++ b/backend/api/src/api/v1/gql/models/mod.rs @@ -1,3 +1,4 @@ pub mod date; +pub mod global_roles; pub mod session; pub mod user; diff --git a/backend/api/src/api/v1/gql/models/session.rs b/backend/api/src/api/v1/gql/models/session.rs index f28176bc..d87ac5bf 100644 --- a/backend/api/src/api/v1/gql/models/session.rs +++ b/backend/api/src/api/v1/gql/models/session.rs @@ -1,4 +1,5 @@ use async_graphql::{ComplexObject, Context, SimpleObject}; +use uuid::Uuid; use super::{date, user::User}; use crate::api::v1::gql::{ @@ -10,11 +11,11 @@ use crate::api::v1::gql::{ #[graphql(complex)] pub struct Session { /// The session's id - pub id: i64, + pub id: Uuid, /// The session's token pub token: String, /// The user who owns this session - pub user_id: i64, + pub user_id: Uuid, /// Expires at pub expires_at: date::DateRFC3339, /// Last used at diff --git a/backend/api/src/api/v1/gql/models/user.rs b/backend/api/src/api/v1/gql/models/user.rs index 3aa66f5e..4b4c63c0 100644 --- a/backend/api/src/api/v1/gql/models/user.rs +++ b/backend/api/src/api/v1/gql/models/user.rs @@ -1,25 +1,31 @@ use async_graphql::{ComplexObject, Context, SimpleObject}; +use uuid::Uuid; use crate::api::v1::gql::{ error::{GqlError, Result}, ext::ContextExt, }; -use common::types::user; +use crate::database::{global_role, user}; -use super::date::DateRFC3339; +use super::{date::DateRFC3339, global_roles::GlobalRole}; #[derive(SimpleObject, Clone)] #[graphql(complex)] pub struct User { - id: i64, - username: String, + pub id: Uuid, + pub display_name: String, + pub username: String, + pub created_at: DateRFC3339, + + // Private fields + #[graphql(skip)] + pub email_: String, #[graphql(skip)] - email_: String, + pub email_verified_: bool, #[graphql(skip)] - email_verified_: bool, - created_at: DateRFC3339, + pub last_login_at_: DateRFC3339, #[graphql(skip)] - last_login_at_: DateRFC3339, + pub stream_key_: String, } /// TODO: find a better way to check if a user is allowed to read a field. @@ -32,8 +38,12 @@ impl User { let session = request_context.get_session(global).await?; - if let Some(session) = session { - if session.user_id == self.id { + if let Some((session, perms)) = session { + if session.user_id == self.id + || perms + .permissions + .has_permission(global_role::Permission::Admin) + { return Ok(&self.email_); } } @@ -49,8 +59,12 @@ impl User { let session = request_context.get_session(global).await?; - if let Some(session) = session { - if session.user_id == self.id { + if let Some((session, perms)) = session { + if session.user_id == self.id + || perms + .permissions + .has_permission(global_role::Permission::Admin) + { return Ok(self.email_verified_); } } @@ -66,8 +80,12 @@ impl User { let session = request_context.get_session(global).await?; - if let Some(session) = session { - if session.user_id == self.id { + if let Some((session, perms)) = session { + if session.user_id == self.id + || perms + .permissions + .has_permission(global_role::Permission::Admin) + { return Ok(&self.last_login_at_); } } @@ -76,17 +94,81 @@ impl User { .with_message("you are not allowed to see this field") .with_field(vec!["lastLoginAt"])) } + + async fn stream_key<'ctx>(&self, ctx: &Context<'_>) -> Result<&str> { + let global = ctx.get_global(); + let request_context = ctx.get_session(); + + let session = request_context.get_session(global).await?; + + if let Some((session, perms)) = session { + if session.user_id == self.id + || perms + .permissions + .has_permission(global_role::Permission::Admin) + { + return Ok(&self.stream_key_); + } + } + + Err(GqlError::Unauthorized + .with_message("you are not allowed to see this field") + .with_field(vec!["stream_key"])) + } + + async fn permissions<'ctx>(&self, ctx: &Context<'_>) -> Result { + let global = ctx.get_global(); + + let global_roles = global + .user_permisions_by_id_loader + .load_one(self.id) + .await + .map_err(|e| { + tracing::error!("failed to fetch global roles: {}", e); + + GqlError::InternalServerError + .with_message("failed to fetch global roles") + .with_field(vec!["permissions"]) + })? + .map(|p| p.permissions) + .unwrap_or_default(); + + Ok(global_roles.bits()) + } + + async fn global_roles<'ctx>(&self, ctx: &Context<'_>) -> Result> { + let global = ctx.get_global(); + + let global_roles = global + .user_permisions_by_id_loader + .load_one(self.id) + .await + .map_err(|e| { + tracing::error!("failed to fetch global roles: {}", e); + + GqlError::InternalServerError + .with_message("failed to fetch global roles") + .with_field(vec!["globalRoles"]) + })? + .map(|p| p.roles.into_iter().map(GlobalRole::from).collect()) + .unwrap_or_default(); + + Ok(global_roles) + } } impl From for User { fn from(value: user::Model) -> Self { + let stream_key = value.get_stream_key(); Self { id: value.id, username: value.username, + display_name: value.display_name, email_: value.email, email_verified_: value.email_verified, created_at: value.created_at.into(), last_login_at_: value.last_login_at.into(), + stream_key_: stream_key, } } } diff --git a/backend/api/src/api/v1/gql/request_context.rs b/backend/api/src/api/v1/gql/request_context.rs index 1c703eb0..fb806c1b 100644 --- a/backend/api/src/api/v1/gql/request_context.rs +++ b/backend/api/src/api/v1/gql/request_context.rs @@ -1,16 +1,18 @@ use std::sync::Arc; +use crate::database::session; use arc_swap::ArcSwap; -use common::types::session; -use crate::{api::v1::gql::error::Result, global::GlobalState}; +use crate::{ + api::v1::gql::error::Result, dataloader::user_permissions::UserPermission, global::GlobalState, +}; use super::error::{GqlError, ResultExt}; #[derive(Default)] pub struct RequestContext { is_websocket: bool, - session: ArcSwap>, + session: ArcSwap>, } impl RequestContext { @@ -21,22 +23,21 @@ impl RequestContext { } } - pub fn set_session(&self, session: Option) { + pub fn set_session(&self, session: Option<(session::Model, UserPermission)>) { self.session.store(Arc::new(session)); } - pub fn is_websocket(&self) -> bool { - self.is_websocket - } - - pub async fn get_session(&self, global: &Arc) -> Result> { + pub async fn get_session( + &self, + global: &Arc, + ) -> Result> { let guard = self.session.load(); let Some(session) = guard.as_ref() else { return Ok(None) }; if !self.is_websocket { - if !session.is_valid() { + if !session.0.is_valid() { return Err(GqlError::InvalidSession.with_message("Session is no longer valid")); } @@ -45,7 +46,7 @@ impl RequestContext { let session = global .session_by_id_loader - .load_one(session.id) + .load_one(session.0.id) .await .map_err_gql("failed to fetch session")? .and_then(|s| if s.is_valid() { Some(s) } else { None }) @@ -54,8 +55,18 @@ impl RequestContext { GqlError::InvalidSession.with_message("Session is no longer valid") })?; - self.session.store(Arc::new(Some(session.clone()))); + let user_permissions = global + .user_permisions_by_id_loader + .load_one(session.user_id) + .await + .map_err_gql("failed to fetch user permissions")? + .ok_or_else(|| { + GqlError::InternalServerError.with_message("failed to fetch user permissions") + })?; + + self.session + .store(Arc::new(Some((session.clone(), user_permissions.clone())))); - Ok(Some(session)) + Ok(Some((session, user_permissions))) } } diff --git a/backend/api/src/api/v1/jwt.rs b/backend/api/src/api/v1/jwt.rs index 9a950efd..0de25129 100644 --- a/backend/api/src/api/v1/jwt.rs +++ b/backend/api/src/api/v1/jwt.rs @@ -1,16 +1,17 @@ use std::sync::Arc; +use crate::database::session; use chrono::{DateTime, TimeZone, Utc}; -use common::types::session; use hmac::{Hmac, Mac}; use jwt::{Claims, Header, RegisteredClaims, SignWithKey, Token, VerifyWithKey}; use sha2::Sha256; +use uuid::Uuid; use crate::global::GlobalState; pub struct JwtState { - pub user_id: i64, - pub session_id: i64, + pub user_id: Uuid, + pub session_id: Uuid, pub expiration: Option>, pub issued_at: DateTime, pub not_before: Option>, @@ -19,11 +20,11 @@ pub struct JwtState { impl JwtState { pub fn serialize(&self, global: &Arc) -> Option { - let key = Hmac::::new_from_slice(global.config.jwt_secret.as_bytes()).ok()?; + let key = Hmac::::new_from_slice(global.config.jwt.secret.as_bytes()).ok()?; let claims = Claims::new(RegisteredClaims { issued_at: Some(self.issued_at.timestamp() as u64), expiration: self.expiration.map(|x| x.timestamp() as u64), - issuer: Some(global.config.jwt_issuer.to_string()), + issuer: Some(global.config.jwt.issuer.to_string()), json_web_token_id: Some(self.session_id.to_string()), subject: Some(self.user_id.to_string()), not_before: self.not_before.map(|x| x.timestamp() as u64), @@ -34,12 +35,12 @@ impl JwtState { } pub fn verify(global: &Arc, token: &str) -> Option { - let key = Hmac::::new_from_slice(global.config.jwt_secret.as_bytes()).ok()?; + let key = Hmac::::new_from_slice(global.config.jwt.secret.as_bytes()).ok()?; let token: Token = token.verify_with_key(&key).ok()?; let claims = token.claims(); - if claims.registered.issuer.clone()? != global.config.jwt_issuer { + if claims.registered.issuer.clone()? != global.config.jwt.issuer { return None; } @@ -70,13 +71,13 @@ impl JwtState { } } - let user_id = claims.registered.subject.clone()?.parse::().ok()?; + let user_id = claims.registered.subject.clone()?.parse::().ok()?; let session_id = claims .registered .json_web_token_id .clone()? - .parse::() + .parse::() .ok()?; let audience = claims.registered.audience.clone(); diff --git a/backend/api/src/config.rs b/backend/api/src/config.rs index 8307cf0f..6a98aaab 100644 --- a/backend/api/src/config.rs +++ b/backend/api/src/config.rs @@ -1,45 +1,193 @@ +use std::net::SocketAddr; + use anyhow::Result; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct TlsConfig { + /// Domain name to use for TLS + /// Only used for gRPC TLS connections + pub domain: Option, + + /// The path to the TLS certificate + pub cert: String, + + /// The path to the TLS private key + pub key: String, + + /// The path to the TLS CA certificate + pub ca_cert: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(default)] -pub struct AppConfig { +pub struct LoggingConfig { /// The log level to use, this is a tracing env filter - pub log_level: String, + pub level: String, + + /// If we should use JSON logging + pub json: bool, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: "info".to_string(), + json: false, + } + } +} - /// The path to the config file. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RmqConfig { + /// The URI to use for connecting to RabbitMQ + pub uri: String, +} + +impl Default for RmqConfig { + fn default() -> Self { + Self { + uri: "amqp://rabbitmq:rabbitmq@localhost:5672/scuffle".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct AppConfig { + /// The path to the config file pub config_file: String, + /// Name of this instance + pub name: String, + + /// The logging config + pub logging: LoggingConfig, + + /// API Config + pub api: ApiConfig, + + /// Database Config + pub database: DatabaseConfig, + + /// Turnstile Config + pub turnstile: TurnstileConfig, + + /// JWT Config + pub jwt: JwtConfig, + + /// GRPC Config + pub grpc: GrpcConfig, + + /// RMQ Config + pub rmq: RmqConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct ApiConfig { /// Bind address for the API - pub bind_address: String, + pub bind_address: SocketAddr, + /// If we should use TLS for the API server + pub tls: Option, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + bind_address: "[::]:4000".parse().expect("failed to parse bind address"), + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct DatabaseConfig { /// The database URL to use - pub database_url: String, + pub uri: String, +} +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + uri: "postgres://root@localhost:5432/scuffle_dev".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct TurnstileConfig { /// The Cloudflare Turnstile site key to use - pub turnstile_secret_key: String, + pub secret_key: String, /// The Cloudflare Turnstile url to use - pub turnstile_url: String, + pub url: String, +} + +impl Default for TurnstileConfig { + fn default() -> Self { + Self { + secret_key: "DUMMY_KEY__SAMPLE_TEXT".to_string(), + url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_string(), + } + } +} +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct JwtConfig { /// JWT secret - pub jwt_secret: String, + pub secret: String, /// JWT issuer - pub jwt_issuer: String, + pub issuer: String, +} + +impl Default for JwtConfig { + fn default() -> Self { + Self { + issuer: "scuffle".to_string(), + secret: "scuffle".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct GrpcConfig { + /// Bind address for the GRPC server + pub bind_address: SocketAddr, + + /// If we should use TLS for the gRPC server + pub tls: Option, +} + +impl Default for GrpcConfig { + fn default() -> Self { + Self { + bind_address: "[::]:50051".parse().expect("failed to parse bind address"), + tls: None, + } + } } impl Default for AppConfig { fn default() -> Self { Self { - log_level: "info".to_string(), config_file: "config".to_string(), - bind_address: "[::]:8080".to_string(), - database_url: "postgres://postgres:postgres@localhost:5432/scuffle-dev".to_string(), - turnstile_secret_key: "DUMMY_KEY__SAMPLE_TEXT".to_string(), - turnstile_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_string(), - jwt_issuer: "scuffle".to_string(), - jwt_secret: "scuffle".to_string(), + name: "scuffle-api".to_string(), + logging: LoggingConfig::default(), + api: ApiConfig::default(), + database: DatabaseConfig::default(), + grpc: GrpcConfig::default(), + jwt: JwtConfig::default(), + turnstile: TurnstileConfig::default(), + rmq: RmqConfig::default(), } } } diff --git a/backend/api/src/database/channel_role.rs b/backend/api/src/database/channel_role.rs new file mode 100644 index 00000000..fe3416c0 --- /dev/null +++ b/backend/api/src/database/channel_role.rs @@ -0,0 +1,36 @@ +use bitmask_enum::bitmask; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +/// A role that can be granted to a user in a channel. +/// Roles can allow or deny permissions to a user. +/// The rank indicates the order in which the role permissions are applied. +/// Roles can have many users granted to them. See the `channel_role_grant` table for more information. +pub struct Model { + /// The unique identifier for the role. + pub id: Uuid, + /// Foreign key to the users table. + pub channel_id: Uuid, + /// The name of the role. + pub name: String, + /// The description of the role. + pub description: String, + /// The rank of the role. (higher rank = priority) unique per channel (-1 is default role) + pub rank: i32, + /// The permissions granted by this role. + pub allowed_permissions: Permission, + /// The permissions denied by this role. + pub denied_permissions: Permission, + /// The time the role was created. + pub created_at: DateTime, +} + +#[bitmask(i64)] +pub enum Permission {} + +impl Default for Permission { + fn default() -> Self { + Self::none() + } +} diff --git a/backend/api/src/database/channel_role_grant.rs b/backend/api/src/database/channel_role_grant.rs new file mode 100644 index 00000000..1c9a1dda --- /dev/null +++ b/backend/api/src/database/channel_role_grant.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +/// A grant of a channel role to a user. +/// This allows for channel owners to grant roles to other users in their channel. +/// See the `channel_role` table for more information. +pub struct Model { + /// The unique identifier for the grant. + pub id: Uuid, + /// Foreign key to the user table. + pub user_id: Uuid, + /// Foreign key to the channel_role table. + pub channel_role_id: Uuid, + /// The time the grant was created. + pub created_at: DateTime, +} diff --git a/backend/api/src/database/global_role.rs b/backend/api/src/database/global_role.rs new file mode 100644 index 00000000..aeec454b --- /dev/null +++ b/backend/api/src/database/global_role.rs @@ -0,0 +1,51 @@ +use bitmask_enum::bitmask; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +/// A role that can be granted to a user globally. +/// Roles can allow or deny permissions to a user. +/// The rank indicates the order in which the role permissions are applied. +/// Roles can have many users granted to them. See the `global_role_grant` table for more information. +pub struct Model { + /// The unique identifier for the role. + pub id: Uuid, + /// The name of the role. + pub name: String, + /// The description of the role. + pub description: String, + /// The rank of the role. (higher rank = priority) (-1 is default role) + pub rank: i64, + /// The permissions granted by this role. + pub allowed_permissions: Permission, + /// The permissions denied by this role. + pub denied_permissions: Permission, + /// The time the role was created. + pub created_at: DateTime, +} + +#[bitmask(i64)] +pub enum Permission { + /// Can do anything + Admin, + /// Can start streaming + GoLive, + /// Has access to transcoded streams + StreamTranscoding, + /// Has access to recorded streams + StreamRecording, +} + +impl Default for Permission { + fn default() -> Self { + Self::none() + } +} + +impl Permission { + /// Checks if the current permission set has the given permission. + /// Admin permissions always return true. Otherwise, the permission is checked against the current permission set. + pub fn has_permission(&self, other: Self) -> bool { + (*self & Self::Admin == Self::Admin) || (*self & other == other) + } +} diff --git a/backend/api/src/database/global_role_grant.rs b/backend/api/src/database/global_role_grant.rs new file mode 100644 index 00000000..e5df3775 --- /dev/null +++ b/backend/api/src/database/global_role_grant.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +/// A grant of a global role to a user. +/// This allows for Admins to grant roles to other users. +/// See the `global_role` table for more information. +pub struct Model { + /// The unique identifier for the grant. + pub id: Uuid, + /// Foreign key to the user table. + pub user_id: Uuid, + /// Foreign key to the global_role table. + pub global_role_id: Uuid, + /// The time the grant was created. + pub created_at: DateTime, +} diff --git a/common/src/types/mod.rs b/backend/api/src/database/mod.rs similarity index 55% rename from common/src/types/mod.rs rename to backend/api/src/database/mod.rs index 8bb023ca..a77bed5a 100644 --- a/common/src/types/mod.rs +++ b/backend/api/src/database/mod.rs @@ -1,13 +1,10 @@ -pub mod channel; -pub mod channel_ban; pub mod channel_role; pub mod channel_role_grant; -pub mod chat_message; -pub mod chat_room; -pub mod follow; -pub mod global_ban; pub mod global_role; pub mod global_role_grant; pub mod session; pub mod stream; +pub mod stream_bitrate_update; +pub mod stream_event; +pub mod stream_variant; pub mod user; diff --git a/backend/api/src/database/session.rs b/backend/api/src/database/session.rs new file mode 100644 index 00000000..1f8703e5 --- /dev/null +++ b/backend/api/src/database/session.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +pub struct Model { + /// The unique identifier for the session. + pub id: Uuid, + /// Foreign key to the user table. + pub user_id: Uuid, + /// The time the session was invalidated. + pub invalidated_at: Option>, + /// The time the session was created. + pub created_at: DateTime, + /// The time the session expires. + pub expires_at: DateTime, + /// The time the session was last used. + pub last_used_at: DateTime, +} + +impl Model { + pub fn is_valid(&self) -> bool { + if self.invalidated_at.is_some() { + return false; + } + + if self.expires_at < Utc::now() { + return false; + } + + true + } +} diff --git a/backend/api/src/database/stream.rs b/backend/api/src/database/stream.rs new file mode 100644 index 00000000..9eb553f7 --- /dev/null +++ b/backend/api/src/database/stream.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, Copy, Eq, PartialEq)] +#[repr(i64)] +pub enum State { + #[default] + NotReady = 0, + Ready = 1, + Stopped = 2, + StoppedResumable = 3, + Failed = 4, + WasReady = 5, +} + +impl From for i64 { + fn from(state: State) -> Self { + match state { + State::NotReady => 0, + State::Ready => 1, + State::Stopped => 2, + State::StoppedResumable => 3, + State::Failed => 4, + State::WasReady => 5, + } + } +} + +impl From for State { + fn from(state: i64) -> Self { + match state { + 0 => State::NotReady, + 1 => State::Ready, + 2 => State::Stopped, + 3 => State::StoppedResumable, + 4 => State::Failed, + 5 => State::WasReady, + _ => State::NotReady, + } + } +} + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Model { + /// The unique identifier for the stream. + pub id: Uuid, + /// The unique identifier for the channel which owns the stream. + pub channel_id: Uuid, + /// The current title of the stream. + pub title: String, + /// The current description of the stream. + pub description: String, + /// Whether or not the stream had recording enabled. + pub recorded: bool, + /// Whether or not the stream had transcoding enabled. + pub transcoded: bool, + /// Whether or not the stream has been deleted. + pub deleted: bool, + /// Whether or not the stream is ready to be viewed. + pub state: State, + /// Ingest Address address of the ingest server controlling the stream. + pub ingest_address: String, + /// The connection which owns the stream. + pub connection_id: Uuid, + /// The time the stream was created. + pub created_at: DateTime, + /// The time the stream was last updated. + /// Used to check if the stream is alive or if its resumable. + pub updated_at: Option>, + /// The time the stream ended. (will be in the future if the stream is live) + pub ended_at: DateTime, +} diff --git a/backend/api/src/database/stream_bitrate_update.rs b/backend/api/src/database/stream_bitrate_update.rs new file mode 100644 index 00000000..24723210 --- /dev/null +++ b/backend/api/src/database/stream_bitrate_update.rs @@ -0,0 +1,11 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Model { + pub stream_id: Uuid, + pub video_bitrate: i64, + pub audio_bitrate: i64, + pub metadata_bitrate: i64, + pub created_at: DateTime, +} diff --git a/backend/api/src/database/stream_event.rs b/backend/api/src/database/stream_event.rs new file mode 100644 index 00000000..195e2845 --- /dev/null +++ b/backend/api/src/database/stream_event.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum Level { + #[default] + Info = 0, + Warning = 1, + Error = 2, +} + +impl From for Level { + fn from(value: i64) -> Self { + match value { + 0 => Self::Info, + 1 => Self::Warning, + 2 => Self::Error, + _ => Self::Info, + } + } +} + +impl From for i64 { + fn from(value: Level) -> Self { + match value { + Level::Info => 0, + Level::Warning => 1, + Level::Error => 2, + } + } +} + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Model { + /// The unique identifier for the stream variant. + pub id: Uuid, + /// The unique identifier for the stream. + pub stream_id: Uuid, + pub title: String, + pub message: String, + pub level: Level, + pub created_at: DateTime, +} diff --git a/backend/api/src/database/stream_variant.rs b/backend/api/src/database/stream_variant.rs new file mode 100644 index 00000000..642e84d7 --- /dev/null +++ b/backend/api/src/database/stream_variant.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Default, sqlx::FromRow)] +pub struct Model { + /// The unique identifier for the stream variant. + pub id: Uuid, + /// The unique identifier for the stream. + pub stream_id: Uuid, + /// The name of the stream variant. + pub name: String, + /// The width of the stream variant. (if null then the stream variant is not a video stream) + pub video_width: Option, + /// The height of the stream variant. (if null then the stream variant is not a video stream) + pub video_height: Option, + /// The framerate of the stream variant. (if null then the stream variant is not a video stream) + pub video_framerate: Option, + /// The bandwidth in bits/s of the stream variant. + pub video_bitrate: Option, + /// Video codec of the stream variant. + pub video_codec: Option, + /// The audio sample rate of the stream variant. + pub audio_sample_rate: Option, + /// The number of audio channels of the stream variant. + pub audio_channels: Option, + /// The bandwidth in bits/s of the stream variant. + pub audio_bitrate: Option, + // Audio Codec of the stream variant. + pub audio_codec: Option, + /// Metadata + pub metadata: Value, + /// The time the stream variant was created. + pub created_at: DateTime, +} diff --git a/common/src/types/user.rs b/backend/api/src/database/user.rs similarity index 59% rename from common/src/types/user.rs rename to backend/api/src/database/user.rs index d6aa1b6e..c51eb0e4 100644 --- a/common/src/types/user.rs +++ b/backend/api/src/database/user.rs @@ -3,19 +3,61 @@ use argon2::{ Argon2, PasswordHash, PasswordHasher, PasswordVerifier, }; use chrono::{DateTime, Utc}; +use rand::Rng; +use uuid::Uuid; + +#[derive(Debug, Clone, Default)] +#[repr(i32)] +pub enum LiveState { + #[default] + NotLive = 0, + Live = 1, + LiveReady = 2, +} + +impl From for LiveState { + fn from(value: i32) -> Self { + match value { + 0 => Self::NotLive, + 1 => Self::Live, + 2 => Self::LiveReady, + _ => Self::NotLive, + } + } +} #[derive(Debug, Clone, Default)] pub struct Model { - pub id: i64, // bigint, primary key - pub username: String, // varchar(32) - pub password_hash: String, // varchar(255) - pub email: String, // varchar(255) - pub email_verified: bool, // bool - pub created_at: DateTime, // timestamptz - pub last_login_at: DateTime, // timestamptz + /// The unique identifier for the user. + pub id: Uuid, + /// The username of the user. + pub username: String, + /// The display name of the user. + pub display_name: String, + /// The hashed password of the user. (argon2) + pub password_hash: String, + /// The email of the user. + pub email: String, + /// Whether the user has verified their email. + pub email_verified: bool, + /// The time the user was created. + pub created_at: DateTime, + /// The time the user last logged in. + pub last_login_at: DateTime, + /// The stream key of the user. + pub stream_key: String, + /// The title of the stream + pub stream_title: String, + /// The description of the stream + pub stream_description: String, + /// Whether the stream transcoding is enabled + pub stream_transcoding_enabled: bool, + /// Whether the stream recording is enabled + pub stream_recording_enabled: bool, } impl Model { + /// Uses argon2 to verify the password hash against the provided password. pub fn verify_password(&self, password: &str) -> bool { let hash = match PasswordHash::new(&self.password_hash) { Ok(hash) => hash, @@ -29,8 +71,13 @@ impl Model { .verify_password(password.as_bytes(), &hash) .is_ok() } + + pub fn get_stream_key(&self) -> String { + format!("live_{}_{}", self.id.as_u128(), self.stream_key) + } } +/// Generates a new password hash using argon2. pub fn hash_password(password: &str) -> String { let salt = SaltString::generate(&mut OsRng); @@ -41,6 +88,7 @@ pub fn hash_password(password: &str) -> String { hash.to_string() } +/// Validates a username. pub fn validate_username(username: &str) -> Result<(), &'static str> { if username.len() < 3 { return Err("Username must be at least 3 characters long"); @@ -60,6 +108,7 @@ pub fn validate_username(username: &str) -> Result<(), &'static str> { Ok(()) } +/// Validates a password. pub fn validate_password(password: &str) -> Result<(), &'static str> { if password.len() < 8 { return Err("Password must be at least 8 characters long"); @@ -88,6 +137,7 @@ pub fn validate_password(password: &str) -> Result<(), &'static str> { Ok(()) } +/// Validates an email. pub fn validate_email(email: &str) -> Result<(), &'static str> { if email.len() < 5 { return Err("Email must be at least 5 characters long"); @@ -111,3 +161,15 @@ pub fn validate_email(email: &str) -> Result<(), &'static str> { Ok(()) } + +/// Generates a new stream key. +pub fn generate_stream_key() -> String { + let mut rng = rand::thread_rng(); + let mut key = String::new(); + + for _ in 0..24 { + key.push(rng.sample(rand::distributions::Alphanumeric).into()); + } + + key +} diff --git a/backend/api/src/dataloader/mod.rs b/backend/api/src/dataloader/mod.rs index f950c0f2..9d57a17d 100644 --- a/backend/api/src/dataloader/mod.rs +++ b/backend/api/src/dataloader/mod.rs @@ -1,2 +1,5 @@ pub mod session; +pub mod stream; +pub mod stream_variant; pub mod user; +pub mod user_permissions; diff --git a/backend/api/src/dataloader/session.rs b/backend/api/src/dataloader/session.rs index 76a15a47..ba8e1159 100644 --- a/backend/api/src/dataloader/session.rs +++ b/backend/api/src/dataloader/session.rs @@ -1,9 +1,10 @@ +use crate::database::session; use async_graphql::{ async_trait::async_trait, dataloader::{DataLoader, Loader}, }; -use common::types::session; use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; pub struct SessionByIdLoader { db: Arc, @@ -16,18 +17,22 @@ impl SessionByIdLoader { } #[async_trait] -impl Loader for SessionByIdLoader { +impl Loader for SessionByIdLoader { type Value = session::Model; type Error = Arc; - async fn load(&self, keys: &[i64]) -> Result, Self::Error> { + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { let results = sqlx::query_as!( session::Model, "SELECT * FROM sessions WHERE id = ANY($1)", &keys ) .fetch_all(&*self.db) - .await?; + .await + .map_err(|e| { + tracing::error!("Failed to fetch sessions: {}", e); + Arc::new(e) + })?; let mut map = HashMap::new(); diff --git a/backend/api/src/dataloader/stream.rs b/backend/api/src/dataloader/stream.rs new file mode 100644 index 00000000..5bd2c50d --- /dev/null +++ b/backend/api/src/dataloader/stream.rs @@ -0,0 +1,45 @@ +use crate::database::stream; +use async_graphql::{ + async_trait::async_trait, + dataloader::{DataLoader, Loader}, +}; +use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; + +pub struct StreamByIdLoader { + db: Arc, +} + +impl StreamByIdLoader { + pub fn new(db: Arc) -> DataLoader { + DataLoader::new(Self { db }, tokio::spawn) + } +} + +#[async_trait] +impl Loader for StreamByIdLoader { + type Value = stream::Model; + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let results = sqlx::query_as!( + stream::Model, + "SELECT * FROM streams WHERE id = ANY($1)", + &keys + ) + .fetch_all(&*self.db) + .await + .map_err(|e| { + tracing::error!("Failed to fetch streams: {}", e); + Arc::new(e) + })?; + + let mut map = HashMap::new(); + + for result in results { + map.insert(result.id, result); + } + + Ok(map) + } +} diff --git a/backend/api/src/dataloader/stream_variant.rs b/backend/api/src/dataloader/stream_variant.rs new file mode 100644 index 00000000..2ad99220 --- /dev/null +++ b/backend/api/src/dataloader/stream_variant.rs @@ -0,0 +1,47 @@ +use crate::database::stream_variant; +use async_graphql::{ + async_trait::async_trait, + dataloader::{DataLoader, Loader}, +}; +use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; + +pub struct StreamVariantsByStreamIdLoader { + db: Arc, +} + +impl StreamVariantsByStreamIdLoader { + pub fn new(db: Arc) -> DataLoader { + DataLoader::new(Self { db }, tokio::spawn) + } +} + +#[async_trait] +impl Loader for StreamVariantsByStreamIdLoader { + type Value = Vec; + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let results = sqlx::query_as!( + stream_variant::Model, + "SELECT * FROM stream_variants WHERE stream_id = ANY($1)", + &keys + ) + .fetch_all(&*self.db) + .await + .map_err(|e| { + tracing::error!("Failed to fetch stream variants: {}", e); + Arc::new(e) + })?; + + let mut map = HashMap::new(); + + for result in results { + map.entry(result.stream_id) + .or_insert_with(Vec::new) + .push(result); + } + + Ok(map) + } +} diff --git a/backend/api/src/dataloader/user.rs b/backend/api/src/dataloader/user.rs index 9bc9aa59..37075705 100644 --- a/backend/api/src/dataloader/user.rs +++ b/backend/api/src/dataloader/user.rs @@ -1,9 +1,10 @@ +use crate::database::user; use async_graphql::{ async_trait::async_trait, dataloader::{DataLoader, Loader}, }; -use common::types::user; use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; pub struct UserByUsernameLoader { db: Arc, @@ -50,14 +51,18 @@ impl UserByIdLoader { } #[async_trait] -impl Loader for UserByIdLoader { +impl Loader for UserByIdLoader { type Value = user::Model; type Error = Arc; - async fn load(&self, keys: &[i64]) -> Result, Self::Error> { + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { let results = sqlx::query_as!(user::Model, "SELECT * FROM users WHERE id = ANY($1)", &keys) .fetch_all(&*self.db) - .await?; + .await + .map_err(|e| { + tracing::error!("Failed to fetch users: {}", e); + Arc::new(e) + })?; let mut map = HashMap::new(); diff --git a/backend/api/src/dataloader/user_permissions.rs b/backend/api/src/dataloader/user_permissions.rs new file mode 100644 index 00000000..c8683ca6 --- /dev/null +++ b/backend/api/src/dataloader/user_permissions.rs @@ -0,0 +1,104 @@ +use crate::database::global_role; +use async_graphql::{ + async_trait::async_trait, + dataloader::{DataLoader, Loader}, +}; +use std::{collections::HashMap, sync::Arc}; +use uuid::Uuid; + +pub struct UserPermissionsByIdLoader { + db: Arc, +} + +impl UserPermissionsByIdLoader { + pub fn new(db: Arc) -> DataLoader { + DataLoader::new(Self { db }, tokio::spawn) + } +} + +#[derive(Debug, Clone, Default)] +pub struct UserPermission { + pub user_id: Uuid, + pub permissions: global_role::Permission, + pub roles: Vec, +} + +#[async_trait] +impl Loader for UserPermissionsByIdLoader { + type Value = UserPermission; + type Error = Arc; + + async fn load(&self, keys: &[Uuid]) -> Result, Self::Error> { + let default_role = sqlx::query_as!( + global_role::Model, + "SELECT * FROM global_roles WHERE rank = -1", + ) + .fetch_optional(&*self.db) + .await + .map_err(|e| { + tracing::error!("Failed to fetch default role: {}", e); + Arc::new(e) + })?; + + let results = sqlx::query!( + "SELECT rg.user_id, r.* FROM global_role_grants rg JOIN global_roles r ON rg.global_role_id = r.id WHERE rg.user_id = ANY($1) ORDER BY rg.user_id, r.rank ASC", + &keys + ) + .fetch_all(&*self.db) + .await.map_err(|e| { + tracing::error!("Failed to fetch user permissions: {}", e); + Arc::new(e) + })?; + + let mut map = HashMap::new(); + + // We only care about the allowed_permissions, because the denied permissions only work on previous roles. + // Since this is the first role, there are no previous roles, so the denied permissions are irrelevant. + if let Some(default_role) = default_role { + for key in keys { + map.insert( + *key, + UserPermission { + user_id: *key, + permissions: default_role.allowed_permissions, + roles: vec![default_role.clone()], + }, + ); + } + } else { + for key in keys { + map.insert( + *key, + UserPermission { + user_id: *key, + permissions: global_role::Permission::default(), + roles: Vec::new(), + }, + ); + } + } + + for result in results { + let current_user = map.entry(result.user_id).or_insert_with(|| UserPermission { + user_id: result.user_id, + permissions: global_role::Permission::default(), + roles: Vec::new(), + }); + + current_user.permissions |= global_role::Permission::from(result.allowed_permissions); + current_user.permissions &= !global_role::Permission::from(result.denied_permissions); + + current_user.roles.push(global_role::Model { + id: result.id, + name: result.name, + description: result.description, + allowed_permissions: result.allowed_permissions.into(), + denied_permissions: result.denied_permissions.into(), + created_at: result.created_at, + rank: result.rank, + }); + } + + Ok(map) + } +} diff --git a/backend/api/src/global/mod.rs b/backend/api/src/global/mod.rs index 5ca5d0f7..f8a29488 100644 --- a/backend/api/src/global/mod.rs +++ b/backend/api/src/global/mod.rs @@ -4,6 +4,9 @@ use async_graphql::dataloader::DataLoader; use common::context::Context; use crate::config::AppConfig; +use crate::dataloader::stream::StreamByIdLoader; +use crate::dataloader::stream_variant::StreamVariantsByStreamIdLoader; +use crate::dataloader::user_permissions::UserPermissionsByIdLoader; use crate::dataloader::{ session::SessionByIdLoader, user::UserByIdLoader, user::UserByUsernameLoader, }; @@ -17,17 +20,30 @@ pub struct GlobalState { pub user_by_username_loader: DataLoader, pub user_by_id_loader: DataLoader, pub session_by_id_loader: DataLoader, + pub user_permisions_by_id_loader: DataLoader, + pub stream_by_id_loader: DataLoader, + pub stream_variants_by_stream_id_loader: DataLoader, + pub rmq: common::rmq::ConnectionPool, } impl GlobalState { - pub fn new(config: AppConfig, db: Arc, ctx: Context) -> Self { + pub fn new( + config: AppConfig, + db: Arc, + rmq: common::rmq::ConnectionPool, + ctx: Context, + ) -> Self { Self { config, ctx, user_by_username_loader: UserByUsernameLoader::new(db.clone()), user_by_id_loader: UserByIdLoader::new(db.clone()), session_by_id_loader: SessionByIdLoader::new(db.clone()), + user_permisions_by_id_loader: UserPermissionsByIdLoader::new(db.clone()), + stream_by_id_loader: StreamByIdLoader::new(db.clone()), + stream_variants_by_stream_id_loader: StreamVariantsByStreamIdLoader::new(db.clone()), db, + rmq, } } } diff --git a/backend/api/src/global/turnstile.rs b/backend/api/src/global/turnstile.rs index 983b5d3f..48039571 100644 --- a/backend/api/src/global/turnstile.rs +++ b/backend/api/src/global/turnstile.rs @@ -10,11 +10,11 @@ impl GlobalState { let body = json!({ "response": token, - "secret": self.config.turnstile_secret_key, + "secret": self.config.turnstile.secret_key, }); let res = client - .post(self.config.turnstile_url.as_str()) + .post(self.config.turnstile.url.as_str()) .header("Content-Type", "application/json") .json(&body) .send() diff --git a/backend/api/src/gql.nocov.rs b/backend/api/src/gql.nocov.rs index 195095cf..fed4cd02 100644 --- a/backend/api/src/gql.nocov.rs +++ b/backend/api/src/gql.nocov.rs @@ -4,6 +4,7 @@ mod api; mod config; +mod database; mod dataloader; mod global; diff --git a/backend/api/src/grpc/api.rs b/backend/api/src/grpc/api.rs new file mode 100644 index 00000000..24efad50 --- /dev/null +++ b/backend/api/src/grpc/api.rs @@ -0,0 +1,510 @@ +use crate::global::GlobalState; +use std::sync::{Arc, Weak}; + +use crate::database::{ + global_role, + stream::{self, State}, + stream_event, stream_variant, +}; +use chrono::{Duration, TimeZone, Utc}; +use sqlx::{Executor, Postgres, QueryBuilder}; +use tonic::{async_trait, Request, Response, Status}; +use uuid::Uuid; + +use super::pb::scuffle::{ + backend::{ + api_server, + update_live_stream_request::{event::Level, update::Update}, + AuthenticateLiveStreamRequest, AuthenticateLiveStreamResponse, LiveStreamState, + NewLiveStreamRequest, NewLiveStreamResponse, UpdateLiveStreamRequest, + UpdateLiveStreamResponse, + }, + types::StreamVariant, +}; + +type Result = std::result::Result; + +pub struct ApiServer { + global: Weak, +} + +impl ApiServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } + + pub fn into_service(self) -> api_server::ApiServer { + api_server::ApiServer::new(self) + } + + async fn insert_stream_variants<'c, T: Executor<'c, Database = Postgres>>( + tx: T, + stream_id: Uuid, + variants: &Vec, + ) -> Result<()> { + // Insert the new stream variants + let mut values = Vec::new(); + + // Unfortunately, we can't use the `sqlx::query!` macro here because it doesn't support + // batch inserts. So we have to build the query manually. This is a bit of a pain, because + // the query is not compile time checked, but it's better than nothing. + let mut query_builder = QueryBuilder::new( + " + INSERT INTO stream_variants ( + id, + stream_id, + name, + video_framerate, + video_height, + video_width, + video_bitrate, + video_codec, + audio_bitrate, + audio_channels, + audio_sample_rate, + audio_codec, + metadata, + created_at + ) ", + ); + + for variant in variants { + let variant_id = variant.id.parse::().map_err(|_| { + Status::invalid_argument("invalid variant ID: must be a valid UUID") + })?; + + values.push(stream_variant::Model { + id: variant_id, + stream_id, + name: variant.name.clone(), + video_framerate: variant.video_settings.as_ref().map(|v| v.framerate as i64), + video_height: variant.video_settings.as_ref().map(|v| v.height as i64), + video_width: variant.video_settings.as_ref().map(|v| v.width as i64), + video_bitrate: variant.video_settings.as_ref().map(|v| v.bitrate as i64), + video_codec: variant.video_settings.as_ref().map(|v| v.codec.clone()), + audio_bitrate: variant.audio_settings.as_ref().map(|a| a.bitrate as i64), + audio_channels: variant.audio_settings.as_ref().map(|a| a.channels as i64), + audio_sample_rate: variant + .audio_settings + .as_ref() + .map(|a| a.sample_rate as i64), + audio_codec: variant.audio_settings.as_ref().map(|a| a.codec.clone()), + metadata: serde_json::from_str(&variant.metadata).unwrap_or_default(), + created_at: Utc::now(), + }) + } + + query_builder.push_values(values, |mut b, variant| { + b.push_bind(variant.id) + .push_bind(variant.stream_id) + .push_bind(variant.name) + .push_bind(variant.video_framerate) + .push_bind(variant.video_height) + .push_bind(variant.video_width) + .push_bind(variant.video_bitrate) + .push_bind(variant.video_codec) + .push_bind(variant.audio_bitrate) + .push_bind(variant.audio_channels) + .push_bind(variant.audio_sample_rate) + .push_bind(variant.audio_codec) + .push_bind(variant.metadata) + .push_bind(variant.created_at); + }); + + query_builder.build().execute(tx).await.map_err(|e| { + tracing::error!("failed to insert stream variants: {}", e); + Status::internal("internal server error") + })?; + + Ok(()) + } +} +#[async_trait] +impl api_server::Api for ApiServer { + async fn authenticate_live_stream( + &self, + request: Request, + ) -> Result> { + let global = self + .global + .upgrade() + .ok_or_else(|| Status::internal("internal server error"))?; + + // Split the stream key into components + let request = request.into_inner(); + + let components = request.stream_key.split('_').collect::>(); + if components.len() != 3 { + return Err(Status::invalid_argument("invalid stream key")); + } + + let (live, channel_id, stream_key) = ( + components[0].to_string(), + components[1].to_string(), + components[2].to_string(), + ); + + if live != "live" { + return Err(Status::invalid_argument("invalid stream key")); + } + + let channel_id = Uuid::from_u128( + channel_id + .parse::() + .map_err(|_| Status::invalid_argument("invalid stream key"))?, + ); + + let channel = global + .user_by_id_loader + .load_one(channel_id) + .await + .map_err(|_| Status::internal("failed to query database"))? + .ok_or_else(|| Status::invalid_argument("invalid stream key"))?; + + if channel.stream_key != stream_key { + return Err(Status::invalid_argument( + "invalid stream key: incorrect stream key", + )); + } + + // Check user permissions + let Ok(permissions) = global.user_permisions_by_id_loader.load_one(channel_id).await else { + return Err(Status::internal("failed to query database")); + }; + + let Some(user_permissions) = permissions else { + return Err(Status::permission_denied("user has no permission to go live")); + }; + + if !user_permissions + .permissions + .has_permission(global_role::Permission::GoLive) + { + return Err(Status::permission_denied( + "user has no permission to go live", + )); + } + + // We need to create a new stream ID for this stream + let mut tx = global.db.begin().await.map_err(|e| { + tracing::error!("failed to begin transaction: {}", e); + Status::internal("internal server error") + })?; + + let record = user_permissions + .permissions + .has_permission(global_role::Permission::StreamRecording) + && channel.stream_recording_enabled; + let transcode = user_permissions + .permissions + .has_permission(global_role::Permission::StreamTranscoding) + && channel.stream_transcoding_enabled; + + let stream = match sqlx::query_as!( + stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id, ended_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *", + channel_id, + channel.stream_title, + channel.stream_description, + record, + transcode, + request.ingest_address, + request.connection_id.parse::().map_err(|_| Status::invalid_argument("invalid connection ID: must be a valid UUID"))?, + Utc::now() + chrono::Duration::seconds(300), + ).fetch_one(&mut *tx).await { + Ok(stream) => stream, + Err(e) => { + tracing::error!("failed to insert stream: {}", e); + return Err(Status::internal("internal server error")); + } + }; + + if let Err(e) = tx.commit().await { + tracing::error!("failed to commit transaction: {}", e); + return Err(Status::internal("internal server error")); + } + + Ok(Response::new(AuthenticateLiveStreamResponse { + stream_id: stream.id.to_string(), + record, + transcode, + try_resume: false, + variants: vec![], + })) + } + + async fn update_live_stream( + &self, + request: Request, + ) -> Result> { + let global = self + .global + .upgrade() + .ok_or_else(|| Status::internal("internal server error"))?; + + let request = request.into_inner(); + + let stream_id = request + .stream_id + .parse::() + .map_err(|_| Status::invalid_argument("invalid stream ID: must be a valid UUID"))?; + + let connection_id = request + .connection_id + .parse::() + .map_err(|_| Status::invalid_argument("invalid connection ID: must be a valid UUID"))?; + + if request.updates.is_empty() { + return Err(Status::invalid_argument("no updates provided")); + } + + let stream = global + .stream_by_id_loader + .load_one(stream_id) + .await + .map_err(|_| Status::internal("failed to query database"))? + .ok_or_else(|| Status::invalid_argument("invalid stream ID"))?; + + if stream.connection_id != connection_id { + return Err(Status::invalid_argument("invalid connection ID")); + } + + if stream.ended_at < Utc::now() + || stream.state == State::Stopped + || stream.state == State::Failed + || stream.state == State::StoppedResumable + { + return Err(Status::invalid_argument("stream has ended")); + } + + let mut tx = global.db.begin().await.map_err(|e| { + tracing::error!("failed to begin transaction: {}", e); + Status::internal("internal server error") + })?; + + for u in request.updates { + let Some(update) = u.update else { + continue; + }; + + match update { + Update::Bitrate(bt) => { + sqlx::query!( + "INSERT INTO stream_bitrate_updates (stream_id, video_bitrate, audio_bitrate, metadata_bitrate, created_at) VALUES ($1, $2, $3, $4, $5)", + stream_id, + bt.video_bitrate as i64, + bt.audio_bitrate as i64, + bt.metadata_bitrate as i64, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap(), + ).execute(&mut *tx).await.map_err(|e| { + tracing::error!("failed to insert stream bitrate update: {}", e); + Status::internal("internal server error") + })?; + + sqlx::query!( + "UPDATE streams SET updated_at = $2, ended_at = $3 WHERE id = $1", + stream_id, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap(), + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap() + + chrono::Duration::seconds(300), + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("failed to insert stream bitrate update: {}", e); + Status::internal("internal server error") + })?; + } + Update::State(st) => { + let state = LiveStreamState::from_i32(st).ok_or_else(|| { + Status::invalid_argument("invalid state: must be a valid state") + })?; + match state { + LiveStreamState::NotReady | LiveStreamState::Ready => { + sqlx::query!( + "UPDATE streams SET state = $2, updated_at = $3, ended_at = $4 WHERE id = $1", + stream_id, + match state { + LiveStreamState::NotReady => State::NotReady as i64, + LiveStreamState::Ready => State::Ready as i64, + _ => unreachable!(), + }, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap(), + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap() + chrono::Duration::seconds(300), + ) + .execute(&*global.db) + .await + .map_err(|e| { + tracing::error!("failed to update stream state: {}", e); + Status::internal("internal server error") + })?; + } + LiveStreamState::StoppedResumable => { + sqlx::query!( + "UPDATE streams SET state = $2, updated_at = $3, ended_at = $4 WHERE id = $1", + stream_id, + State::StoppedResumable as i64, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap(), + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap() + Duration::seconds(300), + ).execute(&*global.db).await.map_err(|e| { + tracing::error!("failed to update stream state: {}", e); + Status::internal("internal server error") + })?; + } + LiveStreamState::Stopped | LiveStreamState::Failed => { + sqlx::query!( + "UPDATE streams SET state = $2, updated_at = $3, ended_at = $3 WHERE id = $1", + stream_id, + match state { + LiveStreamState::Stopped => State::Stopped as i64, + LiveStreamState::Failed => State::Failed as i64, + _ => unreachable!(), + }, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap() + ) + .execute(&*global.db) + .await + .map_err(|e| { + tracing::error!("failed to update stream state: {}", e); + Status::internal("internal server error") + })?; + } + } + } + Update::Event(e) => { + let level = Level::from_i32(e.level).ok_or_else(|| { + Status::invalid_argument("invalid level: must be a valid level") + })?; + let level = match level { + Level::Info => stream_event::Level::Info, + Level::Warning => stream_event::Level::Warning, + Level::Error => stream_event::Level::Error, + }; + + sqlx::query!( + "INSERT INTO stream_events (stream_id, level, title, message, created_at) VALUES ($1, $2, $3, $4, $5)", + stream_id, + level as i64, + e.title, + e.message, + Utc.timestamp_opt(u.timestamp as i64, 0).unwrap(), + ).execute(&mut *tx).await.map_err(|e| { + tracing::error!("failed to insert stream event: {}", e); + Status::internal("internal server error") + })?; + } + Update::Variants(v) => { + ApiServer::insert_stream_variants(&mut *tx, stream_id, &v.variants).await?; + + sqlx::query!( + "UPDATE streams SET updated_at = NOW() WHERE id = $1", + stream_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("failed to insert stream bitrate update: {}", e); + Status::internal("internal server error") + })?; + } + } + } + + if let Err(e) = tx.commit().await { + tracing::error!("failed to commit transaction: {}", e); + return Err(Status::internal("internal server error")); + } + + Ok(Response::new(UpdateLiveStreamResponse {})) + } + + async fn new_live_stream( + &self, + request: Request, + ) -> Result> { + let global = self + .global + .upgrade() + .ok_or_else(|| Status::internal("internal server error"))?; + + let request = request.into_inner(); + + let old_stream_id = request + .old_stream_id + .parse::() + .map_err(|_| Status::invalid_argument("invalid old stream ID: must be a valid UUID"))?; + + let Some(old_stream) = global.stream_by_id_loader.load_one(old_stream_id).await.map_err(|e| { + tracing::error!("failed to load stream by ID: {}", e); + Status::internal("internal server error") + })? else { + return Err(Status::not_found("stream not found")); + }; + + if old_stream.ended_at < Utc::now() + || old_stream.state == State::Stopped + || old_stream.state == State::Failed + { + return Err(Status::failed_precondition("stream has already ended")); + } + + let stream_id = Uuid::new_v4(); + + let mut tx = global.db.begin().await.map_err(|e| { + tracing::error!("failed to begin transaction: {}", e); + Status::internal("internal server error") + })?; + + // Insert the new stream + sqlx::query!( + "INSERT INTO streams (id, channel_id, title, description, state, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7)", + stream_id, + old_stream.channel_id, + old_stream.title, + old_stream.description, + State::NotReady as i64, + old_stream.ingest_address, + old_stream.connection_id, + ).execute(&mut *tx).await.map_err(|e| { + tracing::error!("failed to insert stream: {}", e); + Status::internal("internal server error") + })?; + + // Update the old stream + sqlx::query!( + "UPDATE streams SET state = $2, ended_at = NOW(), updated_at = NOW() WHERE id = $1", + old_stream_id, + State::Stopped as i32, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("failed to update stream: {}", e); + Status::internal("internal server error") + })?; + + ApiServer::insert_stream_variants(&mut *tx, stream_id, &request.variants).await?; + + sqlx::query!( + "UPDATE streams SET updated_at = NOW() WHERE id = $1", + stream_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("failed to insert stream bitrate update: {}", e); + Status::internal("internal server error") + })?; + + if let Err(e) = tx.commit().await { + tracing::error!("failed to commit transaction: {}", e); + return Err(Status::internal("internal server error")); + } + + Ok(Response::new(NewLiveStreamResponse { + stream_id: stream_id.to_string(), + })) + } +} diff --git a/backend/api/src/grpc/health.rs b/backend/api/src/grpc/health.rs new file mode 100644 index 00000000..8dc27bd3 --- /dev/null +++ b/backend/api/src/grpc/health.rs @@ -0,0 +1,70 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status}; + +use super::pb::health::{ + health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, +}; + +pub struct HealthServer { + global: Weak, +} + +impl HealthServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } + + pub fn into_service(self) -> health_server::HealthServer { + health_server::HealthServer::new(self) + } +} + +type Result = std::result::Result; + +#[async_trait] +impl health_server::Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + let serving = self + .global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + Ok(Response::new(HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + })) + } + + async fn watch(&self, _: Request) -> Result> { + let global = self.global.clone(); + + let output = try_stream! { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let serving = global.upgrade().map(|g| !g.ctx.is_done()).unwrap_or_default(); + + yield HealthCheckResponse { + status: if serving { ServingStatus::Serving.into() } else { ServingStatus::NotServing.into() }, + }; + } + }; + + Ok(Response::new(Box::pin(output))) + } +} diff --git a/backend/api/src/grpc/mod.rs b/backend/api/src/grpc/mod.rs new file mode 100644 index 00000000..da18f9b3 --- /dev/null +++ b/backend/api/src/grpc/mod.rs @@ -0,0 +1,47 @@ +use crate::global::GlobalState; +use anyhow::Result; +use std::sync::Arc; +use tokio::select; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; + +pub mod api; +pub mod health; +pub mod pb; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("GRPC Listening on {}", global.config.grpc.bind_address); + + let server = if let Some(tls) = &global.config.grpc.tls { + let cert = tokio::fs::read(&tls.cert).await?; + let key = tokio::fs::read(&tls.key).await?; + let ca_cert = tokio::fs::read(&tls.ca_cert).await?; + tracing::info!("gRPC TLS enabled"); + Server::builder().tls_config( + ServerTlsConfig::new() + .identity(Identity::from_pem(cert, key)) + .client_ca_root(Certificate::from_pem(ca_cert)), + )? + } else { + tracing::info!("gRPC TLS disabled"); + Server::builder() + } + .add_service(api::ApiServer::new(&global).into_service()) + .add_service(health::HealthServer::new(&global).into_service()) + .serve_with_shutdown(global.config.grpc.bind_address, async { + global.ctx.done().await; + }); + + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = server => { + if let Err(r) = r { + tracing::error!("gRPC server failed: {:?}", r); + return Err(r.into()); + } + }, + } + + Ok(()) +} diff --git a/backend/api/src/grpc/pb.rs b/backend/api/src/grpc/pb.rs new file mode 100644 index 00000000..16034e4e --- /dev/null +++ b/backend/api/src/grpc/pb.rs @@ -0,0 +1,17 @@ +pub mod scuffle { + pub mod backend { + tonic::include_proto!("scuffle.backend"); + } + + pub mod types { + tonic::include_proto!("scuffle.types"); + } + + pub mod events { + tonic::include_proto!("scuffle.events"); + } +} + +pub mod health { + tonic::include_proto!("grpc.health.v1"); +} diff --git a/backend/api/src/main.rs b/backend/api/src/main.rs index 54014a17..6a6dd82f 100644 --- a/backend/api/src/main.rs +++ b/backend/api/src/main.rs @@ -2,15 +2,17 @@ use std::{str::FromStr, sync::Arc, time::Duration}; -use anyhow::Result; -use common::{context::Context, logging, signal}; +use anyhow::{Context as _, Result}; +use common::{context::Context, logging, prelude::FutureTimeout, signal}; use sqlx::{postgres::PgConnectOptions, ConnectOptions}; use tokio::{select, signal::unix::SignalKind, time}; -pub mod api; -pub mod config; -pub mod dataloader; -pub mod global; +mod api; +mod config; +mod database; +mod dataloader; +mod global; +mod grpc; #[cfg(test)] mod tests; @@ -18,13 +20,13 @@ mod tests; #[tokio::main] async fn main() -> Result<()> { let config = config::AppConfig::parse()?; - logging::init(&config.log_level)?; + logging::init(&config.logging.level, config.logging.json)?; tracing::info!("starting: loaded config from {}", config.config_file); let db = Arc::new( sqlx::PgPool::connect_with( - PgConnectOptions::from_str(&config.database_url)? + PgConnectOptions::from_str(&config.database.uri)? .disable_statement_logging() .to_owned(), ) @@ -33,9 +35,21 @@ async fn main() -> Result<()> { let (ctx, handler) = Context::new(); - let global = Arc::new(global::GlobalState::new(config, db, ctx)); + let rmq = common::rmq::ConnectionPool::connect( + config.rmq.uri.clone(), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .context("failed to connect to rabbitmq, timedout")? + .context("failed to connect to rabbitmq")?; + + let global = Arc::new(global::GlobalState::new(config, db, rmq, ctx)); let api_future = tokio::spawn(api::run(global.clone())); + let grpc_future = tokio::spawn(grpc::run(global.clone())); // Listen on both sigint and sigterm and cancel the context when either is received let mut signal_handler = signal::SignalHandler::new() @@ -44,6 +58,8 @@ async fn main() -> Result<()> { select! { r = api_future => tracing::error!("api stopped unexpectedly: {:?}", r), + r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), + r = global.rmq.handle_reconnects() => tracing::error!("rmq stopped unexpectedly: {:?}", r), _ = signal_handler.recv() => tracing::info!("shutting down"), } diff --git a/backend/api/src/tests/api/mod.rs b/backend/api/src/tests/api/mod.rs index 375529d1..3ae888b2 100644 --- a/backend/api/src/tests/api/mod.rs +++ b/backend/api/src/tests/api/mod.rs @@ -1,16 +1,25 @@ use std::time::Duration; +use common::prelude::FutureTimeout; use hyper::StatusCode; -use crate::{api::run, config::AppConfig, tests::global::mock_global_state}; +use crate::{ + api::run, + config::{ApiConfig, AppConfig}, + tests::global::mock_global_state, +}; mod errors; mod v1; #[tokio::test] async fn test_api_v6() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); let (global, handler) = mock_global_state(AppConfig { - bind_address: "[::]:8081".to_string(), + api: ApiConfig { + bind_address: format!("[::]:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -22,7 +31,7 @@ async fn test_api_v6() { let client = reqwest::Client::new(); let resp = client - .get("http://localhost:8081/v1/health") + .get(format!("http://localhost:{}/v1/health", port)) .send() .await .expect("failed to get health"); @@ -34,10 +43,13 @@ async fn test_api_v6() { // The client uses Keep-Alive, so we need to drop it to release the global context drop(client); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); - tokio::time::timeout(Duration::from_secs(1), handle) + handle + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel api") .expect("api failed") @@ -46,8 +58,13 @@ async fn test_api_v6() { #[tokio::test] async fn test_api_v4() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { - bind_address: "0.0.0.0:8081".to_string(), + api: ApiConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -59,7 +76,7 @@ async fn test_api_v4() { let client = reqwest::Client::new(); let resp = client - .get("http://localhost:8081/v1/health") + .get(format!("http://localhost:{}/v1/health", port)) .send() .await .expect("failed to get health"); @@ -71,27 +88,15 @@ async fn test_api_v4() { // The client uses Keep-Alive, so we need to drop it to release the global context drop(client); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); - tokio::time::timeout(Duration::from_secs(1), handle) + handle + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel api") .expect("api failed") .expect("api failed"); } - -#[tokio::test] -async fn test_api_bad_bind() { - let (global, handler) = mock_global_state(AppConfig { - bind_address: "???".to_string(), - ..Default::default() - }) - .await; - - assert!(run(global).await.is_err()); - - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) - .await - .expect("failed to cancel context"); -} diff --git a/backend/api/src/tests/api/v1/gql/auth.rs b/backend/api/src/tests/api/v1/gql/auth.rs index 6b40c892..845a482e 100644 --- a/backend/api/src/tests/api/v1/gql/auth.rs +++ b/backend/api/src/tests/api/v1/gql/auth.rs @@ -1,25 +1,30 @@ use std::{sync::Arc, time::Duration}; +use crate::database::{session, user}; use async_graphql::{Name, Request, Variables}; use chrono::Utc; -use common::types::{session, user}; +use common::prelude::FutureTimeout; use serde_json::json; +use serial_test::serial; use crate::{ api::v1::{ gql::{ext::RequestExt, request_context::RequestContext, schema}, jwt::JwtState, }, - config::AppConfig, + config::{AppConfig, TurnstileConfig}, tests::global::{mock_global_state, turnstile::mock_turnstile}, }; +#[serial] #[tokio::test] -async fn test_login() { +async fn test_serial_login() { let (mut rx, addr, h1) = mock_turnstile().await; let (global, handler) = mock_global_state(AppConfig { - turnstile_url: addr, - turnstile_secret_key: "DUMMY_KEY__DEADBEEF".to_string(), + turnstile: TurnstileConfig { + url: addr, + secret_key: "DUMMY_KEY__DEADBEEF".to_string(), + }, ..Default::default() }) .await; @@ -28,14 +33,14 @@ async fn test_login() { .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); @@ -59,28 +64,26 @@ async fn test_login() { }); let ctx = Arc::new(RequestContext::new(false)); - let res = tokio::time::timeout( - Duration::from_secs(1), - schema.execute( + let res = schema + .execute( Request::from(query) .provide_global(global.clone()) .provide_context(ctx), - ), - ) - .await - .unwrap(); + ) + .timeout(Duration::from_secs(5)) + .await + .unwrap(); assert_eq!(res.errors.len(), 0); let json = res.data.into_json(); assert!(json.is_ok()); - let session = tokio::time::timeout( - Duration::from_secs(1), - sqlx::query_as!(session::Model, "SELECT * FROM sessions").fetch_one(&*global.db), - ) - .await - .unwrap() - .unwrap(); + let session = sqlx::query_as!(session::Model, "SELECT * FROM sessions") + .fetch_one(&*global.db) + .timeout(Duration::from_secs(5)) + .await + .unwrap() + .unwrap(); let jwt_state = JwtState::from(session); @@ -95,28 +98,27 @@ async fn test_login() { h1.abort(); - tokio::time::timeout(Duration::from_secs(1), h1) - .await - .unwrap() - .ok(); // ignore error because we aborted it - tokio::time::timeout(Duration::from_secs(1), h2) - .await - .unwrap() - .unwrap(); + h1.timeout(Duration::from_secs(1)).await.unwrap().ok(); // ignore error because we aborted it + h2.timeout(Duration::from_secs(1)).await.unwrap().unwrap(); drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_login_while_logged_in() { +async fn test_serial_login_while_logged_in() { let (mut rx, addr, h1) = mock_turnstile().await; let (global, handler) = mock_global_state(AppConfig { - turnstile_url: addr, - turnstile_secret_key: "batman's chest".to_string(), + turnstile: TurnstileConfig { + url: addr, + secret_key: "batman's chest".to_string(), + }, ..Default::default() }) .await; @@ -126,11 +128,11 @@ async fn test_login_while_logged_in() { .await .unwrap(); sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4)", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .execute(&*global.db) .await @@ -158,28 +160,26 @@ async fn test_login_while_logged_in() { resp.send(true).unwrap(); }); - let res = tokio::time::timeout( - Duration::from_secs(1), - schema.execute( + let res = schema + .execute( Request::from(query) .provide_context(ctx) .provide_global(global.clone()), - ), - ) - .await - .unwrap(); + ) + .timeout(Duration::from_secs(1)) + .await + .unwrap(); assert_eq!(res.errors.len(), 0); let json = res.data.into_json(); assert!(json.is_ok()); - let session = tokio::time::timeout( - Duration::from_secs(1), - sqlx::query_as!(session::Model, "SELECT * FROM sessions").fetch_one(&*global.db), - ) - .await - .unwrap() - .unwrap(); + let session = sqlx::query_as!(session::Model, "SELECT * FROM sessions") + .fetch_one(&*global.db) + .timeout(Duration::from_secs(1)) + .await + .unwrap() + .unwrap(); let jwt_state = JwtState::from(session); @@ -194,46 +194,43 @@ async fn test_login_while_logged_in() { h1.abort(); - tokio::time::timeout(Duration::from_secs(1), h1) - .await - .unwrap() - .ok(); // ignore error because we aborted it + h1.timeout(Duration::from_secs(1)).await.unwrap().ok(); // ignore error because we aborted it - tokio::time::timeout(Duration::from_secs(1), h2) - .await - .unwrap() - .unwrap(); + h2.timeout(Duration::from_secs(1)).await.unwrap().unwrap(); drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_login_with_token() { +async fn test_serial_login_with_token() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let user = sqlx::query!( + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key() ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); + let session = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 1, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() + chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -282,34 +279,36 @@ async fn test_login_with_token() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_login_with_session_expired() { +async fn test_serial_login_with_session_expired() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let user = sqlx::query!( + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key() ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); let session = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 1, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() - chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -371,34 +370,36 @@ async fn test_login_with_session_expired() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_login_while_logged_in_with_session_expired() { +async fn test_serial_login_while_logged_in_with_session_expired() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let user = sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key() ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); let session = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 1, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() - chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -407,9 +408,8 @@ async fn test_login_while_logged_in_with_session_expired() { let session2 = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 2, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() + chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -436,7 +436,7 @@ async fn test_login_while_logged_in_with_session_expired() { ); let ctx = Arc::new(RequestContext::new(true)); - ctx.set_session(Some(session)); + ctx.set_session(Some((session, Default::default()))); let res = tokio::time::timeout( Duration::from_secs(1), @@ -462,17 +462,22 @@ async fn test_login_while_logged_in_with_session_expired() { ); drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_register() { +async fn test_serial_register() { let (mut rx, addr, h1) = mock_turnstile().await; let (global, handler) = mock_global_state(AppConfig { - turnstile_url: addr, - turnstile_secret_key: "DUMMY_KEY__LOREM_IPSUM".to_string(), + turnstile: TurnstileConfig { + url: addr, + secret_key: "DUMMY_KEY__LOREM_IPSUM".to_string(), + }, ..Default::default() }) .await; @@ -503,7 +508,7 @@ async fn test_register() { let ctx = Arc::new(RequestContext::new(false)); let res = tokio::time::timeout( - Duration::from_secs(1), + Duration::from_secs(2), schema.execute( Request::from(query) .provide_global(global.clone()) @@ -544,45 +549,41 @@ async fn test_register() { h1.abort(); - tokio::time::timeout(Duration::from_secs(1), h1) - .await - .unwrap() - .ok(); // ignore error because we aborted it - tokio::time::timeout(Duration::from_secs(1), h2) - .await - .unwrap() - .unwrap(); + h1.timeout(Duration::from_secs(1)).await.unwrap().ok(); // ignore error because we aborted it + h2.timeout(Duration::from_secs(1)).await.unwrap().unwrap(); drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_logout() { +async fn test_serial_logout() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let user = sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); let session = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 1, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() + chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -599,7 +600,7 @@ async fn test_logout() { "#; let ctx = Arc::new(RequestContext::new(false)); - ctx.set_session(Some(session)); + ctx.set_session(Some((session, Default::default()))); let res = tokio::time::timeout( Duration::from_secs(1), @@ -632,34 +633,36 @@ async fn test_logout() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } +#[serial] #[tokio::test] -async fn test_logout_with_token() { +async fn test_serial_logout_with_token() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") .execute(&*global.db) .await .unwrap(); - sqlx::query!( - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let user = sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key() ) - .execute(&*global.db) + .fetch_one(&*global.db) .await .unwrap(); let session = sqlx::query_as!( session::Model, - "INSERT INTO sessions(id, user_id, expires_at) VALUES ($1, $2, $3) RETURNING *", - 1, - 1, + "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", + user.id, Utc::now() + chrono::Duration::seconds(60) ) .fetch_one(&*global.db) @@ -713,7 +716,9 @@ async fn test_logout_with_token() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } diff --git a/backend/api/src/tests/api/v1/gql/mod.rs b/backend/api/src/tests/api/v1/gql/mod.rs index 00673ca0..390030ee 100644 --- a/backend/api/src/tests/api/v1/gql/mod.rs +++ b/backend/api/src/tests/api/v1/gql/mod.rs @@ -1,15 +1,15 @@ -use std::time::Duration; - use async_graphql::http::WebSocketProtocols; +use common::prelude::FutureTimeout; use futures_util::{SinkExt, StreamExt}; use http::HeaderValue; use hyper_tungstenite::tungstenite::client::IntoClientRequest; use serde_json::json; +use std::time::Duration; use crate::{ api, api::v1::gql::{schema, PLAYGROUND_HTML}, - config::AppConfig, + config::{ApiConfig, AppConfig}, tests::global::mock_global_state, }; @@ -47,8 +47,13 @@ async fn test_subscription_noop() { #[tokio::test] async fn test_query_noop_via_http() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { - bind_address: "0.0.0.0:8081".to_string(), + api: ApiConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -59,7 +64,7 @@ async fn test_query_noop_via_http() { let client = reqwest::Client::new(); let res = client - .post("http://localhost:8081/v1/gql") + .post(format!("http://localhost:{}/v1/gql", port)) .json(&serde_json::json!({ "query": "query { noop }", })) @@ -75,7 +80,7 @@ async fn test_query_noop_via_http() { ); let res = client - .get("http://localhost:8081/v1/gql") + .get(format!("http://localhost:{}/v1/gql", port)) .query(&[("query", "query { noop }")]) .send() .await @@ -91,7 +96,9 @@ async fn test_query_noop_via_http() { drop(client); // Connect via websocket - let mut req = "ws://localhost:8081/v1/gql".into_client_request().unwrap(); + let mut req = format!("ws://localhost:{}/v1/gql", port) + .into_client_request() + .unwrap(); req.headers_mut().insert( http::header::SEC_WEBSOCKET_PROTOCOL, HeaderValue::from_static(WebSocketProtocols::GraphQLWS.sec_websocket_protocol()), @@ -177,10 +184,12 @@ async fn test_query_noop_via_http() { ws_stream.next().await; // Wait for the server to shutdown - tokio::time::timeout(std::time::Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(std::time::Duration::from_secs(1)) .await .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(1), h) + h.timeout(std::time::Duration::from_secs(1)) .await .unwrap() .unwrap() @@ -189,8 +198,13 @@ async fn test_query_noop_via_http() { #[tokio::test] async fn test_playground() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { - bind_address: "0.0.0.0:8081".to_string(), + api: ApiConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -201,7 +215,7 @@ async fn test_playground() { let client = reqwest::Client::new(); let res = client - .get("http://localhost:8081/v1/gql/playground") + .get(format!("http://localhost:{}/v1/gql/playground", port)) .send() .await .unwrap(); @@ -217,10 +231,12 @@ async fn test_playground() { drop(client); // Wait for the server to shutdown - tokio::time::timeout(std::time::Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(std::time::Duration::from_secs(1)) .await .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(1), h) + h.timeout(std::time::Duration::from_secs(1)) .await .unwrap() .unwrap() diff --git a/backend/api/src/tests/api/v1/gql/models/global_roles.rs b/backend/api/src/tests/api/v1/gql/models/global_roles.rs new file mode 100644 index 00000000..f6e81ac2 --- /dev/null +++ b/backend/api/src/tests/api/v1/gql/models/global_roles.rs @@ -0,0 +1,168 @@ +use crate::api::v1::gql::{ext::RequestExt, request_context::RequestContext, schema}; +use crate::database::{global_role::Permission, user}; +use crate::tests::global::mock_global_state; +use async_graphql::{Name, Request, Value, Variables}; +use common::prelude::FutureTimeout; +use serial_test::serial; +use std::sync::Arc; +use std::time::Duration; + +#[serial] +#[tokio::test] +async fn test_serial_user_by_name() { + let (global, handler) = mock_global_state(Default::default()).await; + + sqlx::query!("DELETE FROM users") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_roles") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*global.db) + .await + .unwrap(); + + let user_id = sqlx::query!( + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", + "admin", + "admin@admin.com", + user::hash_password("admin"), + user::generate_stream_key(), + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let admin_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "admin", + "admin", + 1, + Permission::Admin.bits(), + 0, + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "go_live", + "go_live", + 2, + Permission::GoLive.bits(), + 0, + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + admin_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + go_live_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + let schema = schema(); + + { + let query = r#" + query($id: UUID!) { + userById(id: $id) { + id + permissions + globalRoles { + id + name + description + rank + allowedPermissions + deniedPermissions + } + } + } + "#; + + let mut variables = Variables::default(); + variables.insert(Name::new("id"), Value::String(user_id.to_string())); + + let ctx = Arc::new(RequestContext::new(false)); + + let res = tokio::time::timeout( + Duration::from_secs(1), + schema.execute( + Request::from(query) + .variables(variables) + .provide_global(global.clone()) + .provide_context(ctx), + ), + ) + .await + .unwrap(); + + assert!(res.is_ok()); + assert_eq!(res.errors.len(), 0); + let json = res.data.into_json(); + assert!(json.is_ok()); + + assert_eq!( + json.unwrap(), + serde_json::json!({ + "userById": { + "id": user_id, + "permissions": 3, // admin and go live permissions + "globalRoles": [ + { + "id": admin_role_id, + "name": "admin", + "description": "admin", + "rank": 1, + "allowedPermissions": 1, // admin permission + "deniedPermissions": 0 + }, + { + "id": go_live_role_id, + "name": "go_live", + "description": "go_live", + "rank": 2, + "allowedPermissions": 2, // go live permission + "deniedPermissions": 0 + }, + ] + } + }) + ); + } + + drop(global); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); +} diff --git a/backend/api/src/tests/api/v1/gql/models/mod.rs b/backend/api/src/tests/api/v1/gql/models/mod.rs index 5baff727..3d428b77 100644 --- a/backend/api/src/tests/api/v1/gql/models/mod.rs +++ b/backend/api/src/tests/api/v1/gql/models/mod.rs @@ -1,3 +1,4 @@ mod date; +mod global_roles; mod session; mod user; diff --git a/backend/api/src/tests/api/v1/gql/models/session.rs b/backend/api/src/tests/api/v1/gql/models/session.rs index f5878fe6..4de652cc 100644 --- a/backend/api/src/tests/api/v1/gql/models/session.rs +++ b/backend/api/src/tests/api/v1/gql/models/session.rs @@ -1,14 +1,16 @@ -use std::sync::Arc; -use std::time::Duration; - use crate::api::v1::gql::{ext::RequestExt, request_context::RequestContext, schema}; use crate::api::v1::jwt::JwtState; +use crate::database::{session, user}; use crate::tests::global::mock_global_state; use async_graphql::{Request, Variables}; -use common::types::{session, user}; +use common::prelude::FutureTimeout; +use serial_test::serial; +use std::sync::Arc; +use std::time::Duration; +#[serial] #[tokio::test] -async fn test_session_user() { +async fn test_serial_session_user() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") @@ -17,11 +19,11 @@ async fn test_session_user() { .unwrap(); let user = sqlx::query_as!(user::Model, - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .fetch_one(&*global.db) .await @@ -30,7 +32,7 @@ async fn test_session_user() { let session = sqlx::query_as!( session::Model, "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", - 1, + user.id, chrono::Utc::now() + chrono::Duration::seconds(30) ) .fetch_one(&*global.db) @@ -88,7 +90,9 @@ async fn test_session_user() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } diff --git a/backend/api/src/tests/api/v1/gql/models/user.rs b/backend/api/src/tests/api/v1/gql/models/user.rs index b1673cc8..8df44ae2 100644 --- a/backend/api/src/tests/api/v1/gql/models/user.rs +++ b/backend/api/src/tests/api/v1/gql/models/user.rs @@ -1,13 +1,15 @@ -use std::sync::Arc; -use std::time::Duration; - use crate::api::v1::gql::{ext::RequestExt, request_context::RequestContext, schema}; +use crate::database::{session, user}; use crate::tests::global::mock_global_state; use async_graphql::{Request, Value}; -use common::types::{session, user}; +use common::prelude::FutureTimeout; +use serial_test::serial; +use std::sync::Arc; +use std::time::Duration; +#[serial] #[tokio::test] -async fn test_user_by_name() { +async fn test_serial_user_by_name() { let (global, handler) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") @@ -16,11 +18,11 @@ async fn test_user_by_name() { .unwrap(); let user = sqlx::query_as!(user::Model, - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .fetch_one(&*global.db) .await @@ -29,7 +31,7 @@ async fn test_user_by_name() { let session = sqlx::query_as!( session::Model, "INSERT INTO sessions(user_id, expires_at) VALUES ($1, $2) RETURNING *", - 1, + user.id, chrono::Utc::now() + chrono::Duration::seconds(30) ) .fetch_one(&*global.db) @@ -217,12 +219,13 @@ async fn test_user_by_name() { lastLoginAt username createdAt + streamKey } } "#; let ctx = Arc::new(RequestContext::new(false)); - ctx.set_session(Some(session.clone())); + ctx.set_session(Some((session.clone(), Default::default()))); let res = tokio::time::timeout( Duration::from_secs(1), @@ -242,7 +245,7 @@ async fn test_user_by_name() { assert_eq!( json.unwrap(), - serde_json::json!({ "userByUsername": { "id": user.id, "email": user.email, "emailVerified": user.email_verified, "lastLoginAt": user.last_login_at.to_rfc3339(), "username": user.username, "createdAt": user.created_at.to_rfc3339() } }) + serde_json::json!({ "userByUsername": { "id": user.id, "email": user.email, "emailVerified": user.email_verified, "lastLoginAt": user.last_login_at.to_rfc3339(), "username": user.username, "createdAt": user.created_at.to_rfc3339(), "streamKey": format!("live_{}_{}", user.id.as_u128(), user.stream_key) } }) ); } @@ -264,7 +267,7 @@ async fn test_user_by_name() { "#; let ctx = Arc::new(RequestContext::new(true)); - ctx.set_session(Some(session.clone())); + ctx.set_session(Some((session.clone(), Default::default()))); let res = tokio::time::timeout( Duration::from_secs(1), @@ -296,7 +299,9 @@ async fn test_user_by_name() { drop(global); - tokio::time::timeout(Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_secs(1)) .await .expect("failed to cancel context"); } diff --git a/backend/api/src/tests/api/v1/middleware/auth.rs b/backend/api/src/tests/api/v1/middleware/auth.rs index 7810e413..a169e23d 100644 --- a/backend/api/src/tests/api/v1/middleware/auth.rs +++ b/backend/api/src/tests/api/v1/middleware/auth.rs @@ -1,21 +1,27 @@ -use core::time; - +use crate::database::{session, user}; use chrono::{Duration, Utc}; -use common::types::session; +use common::prelude::FutureTimeout; +use core::time; use http::header; use serde_json::{json, Value}; +use serial_test::serial; use crate::{ api::{self, v1::jwt::JwtState}, - config::AppConfig, + config::{ApiConfig, AppConfig}, tests::global::mock_global_state, }; +#[serial] #[tokio::test] -async fn test_auth_middleware() { +async fn test_serial_auth_middleware() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); let (global, handler) = mock_global_state(AppConfig { - bind_address: "0.0.0.0:8081".to_string(), + api: ApiConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -24,21 +30,22 @@ async fn test_auth_middleware() { .execute(&*global.db) .await .expect("failed to clear users"); - sqlx::query!( - "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let id = sqlx::query!( + "INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", "test", "test@test.com", - "$2b$1" + user::hash_password("test"), + user::generate_stream_key(), ) - .execute(&*global.db) + .map(|row| row.id) + .fetch_one(&*global.db) .await .expect("failed to insert user"); let session = sqlx::query_as!( session::Model, "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", - 1, + id, Utc::now() + Duration::seconds(30) ) .fetch_one(&*global.db) @@ -58,7 +65,7 @@ async fn test_auth_middleware() { let client = reqwest::Client::new(); let resp = client - .get("http://localhost:8081/v1/health") + .get(format!("http://localhost:{}/v1/health", port)) .header(header::AUTHORIZATION, format!("Bearer {}", token)) .send() .await @@ -77,7 +84,7 @@ async fn test_auth_middleware() { .expect("failed to update session"); let resp = client - .get("http://localhost:8081/v1/health") + .get(format!("http://localhost:{}/v1/health", port)) .header(header::AUTHORIZATION, format!("Bearer {}", token)) .send() .await @@ -97,21 +104,29 @@ async fn test_auth_middleware() { drop(global); drop(client); - tokio::time::timeout(time::Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(time::Duration::from_secs(1)) .await .expect("failed to cancel context"); - tokio::time::timeout(time::Duration::from_secs(1), handle) + handle + .timeout(time::Duration::from_secs(1)) .await .unwrap() .unwrap() .unwrap(); } +#[serial] #[tokio::test] -async fn test_auth_middleware_failed() { +async fn test_serial_auth_middleware_failed() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); let (global, handler) = mock_global_state(AppConfig { - bind_address: "0.0.0.0:8081".to_string(), + api: ApiConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: None, + }, ..Default::default() }) .await; @@ -120,21 +135,22 @@ async fn test_auth_middleware_failed() { .execute(&*global.db) .await .expect("failed to clear users"); - sqlx::query!( - "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)", - 1, + let id = sqlx::query!( + "INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", "test", "test@test.com", - "$2b$1" + user::hash_password("test"), + user::generate_stream_key(), ) - .execute(&*global.db) + .map(|row| row.id) + .fetch_one(&*global.db) .await .expect("failed to insert user"); let session = sqlx::query_as!( session::Model, "INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING *", - 1, + id, Utc::now() - Duration::seconds(30) ) .fetch_one(&*global.db) @@ -152,7 +168,7 @@ async fn test_auth_middleware_failed() { let client = reqwest::Client::new(); let resp = client - .get("http://localhost:8081/v1/health") + .get(format!("http://localhost:{}/v1/health", port)) .header(header::AUTHORIZATION, format!("Bearer {}", token)) .header("X-Auth-Token-Check", "always") .send() @@ -172,11 +188,14 @@ async fn test_auth_middleware_failed() { // The client uses Keep-Alive, so we need to drop it to release the global context drop(client); - tokio::time::timeout(time::Duration::from_secs(1), handler.cancel()) + handler + .cancel() + .timeout(time::Duration::from_secs(1)) .await .expect("failed to cancel context"); - tokio::time::timeout(time::Duration::from_secs(1), handle) + handle + .timeout(time::Duration::from_secs(1)) .await .unwrap() .unwrap() diff --git a/backend/api/src/tests/config.rs b/backend/api/src/tests/config.rs index 483b5c0f..e4970d9b 100644 --- a/backend/api/src/tests/config.rs +++ b/backend/api/src/tests/config.rs @@ -1,40 +1,64 @@ +use serial_test::serial; + use crate::config::AppConfig; +fn clear_env() { + for (key, _) in std::env::vars() { + if key.starts_with("SCUF_") { + std::env::remove_var(key); + } + } +} + +#[serial] #[test] fn test_parse() { + clear_env(); + let config = AppConfig::parse().expect("Failed to parse config"); assert_eq!(config, AppConfig::default()); } +#[serial] #[test] fn test_parse_env() { - std::env::set_var("SCUF_LOG_LEVEL", "api=debug"); - std::env::set_var("SCUF_BIND_ADDRESS", "[::]:8081"); + clear_env(); + + std::env::set_var("SCUF_LOGGING__LEVEL", "api=debug"); + std::env::set_var("SCUF_API__BIND_ADDRESS", "[::]:8081"); std::env::set_var( - "SCUF_DATABASE_URL", + "SCUF_DATABASE__URI", "postgres://postgres:postgres@localhost:5433/postgres", ); let config = AppConfig::parse().expect("Failed to parse config"); - assert_eq!(config.log_level, "api=debug"); - assert_eq!(config.bind_address, "[::]:8081"); + assert_eq!(config.logging.level, "api=debug"); + assert_eq!(config.api.bind_address, "[::]:8081".parse().unwrap()); assert_eq!( - config.database_url, + config.database.uri, "postgres://postgres:postgres@localhost:5433/postgres" ); } +#[serial] #[test] fn test_parse_file() { + clear_env(); + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let config_file = tmp_dir.path().join("config.toml"); std::fs::write( &config_file, r#" -log_level = "api=debug" +[logging] +level = "api=debug" + +[api] bind_address = "[::]:8081" -database_url = "postgres://postgres:postgres@localhost:5433/postgres" + +[database] +uri = "postgres://postgres:postgres@localhost:5433/postgres" "#, ) .expect("Failed to write config file"); @@ -46,10 +70,10 @@ database_url = "postgres://postgres:postgres@localhost:5433/postgres" let config = AppConfig::parse().expect("Failed to parse config"); - assert_eq!(config.log_level, "api=debug"); - assert_eq!(config.bind_address, "[::]:8081"); + assert_eq!(config.logging.level, "api=debug"); + assert_eq!(config.api.bind_address, "[::]:8081".parse().unwrap()); assert_eq!( - config.database_url, + config.database.uri, "postgres://postgres:postgres@localhost:5433/postgres" ); assert_eq!( @@ -58,17 +82,25 @@ database_url = "postgres://postgres:postgres@localhost:5433/postgres" ); } +#[serial] #[test] fn test_parse_file_env() { + clear_env(); + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let config_file = tmp_dir.path().join("config.toml"); std::fs::write( &config_file, r#" -log_level = "api=debug" +[logging] +level = "api=debug" + +[api] bind_address = "[::]:8081" -database_url = "postgres://postgres:postgres@localhost:5433/postgres" + +[database] +uri = "postgres://postgres:postgres@localhost:5433/postgres" "#, ) .expect("Failed to write config file"); @@ -77,14 +109,14 @@ database_url = "postgres://postgres:postgres@localhost:5433/postgres" "SCUF_CONFIG_FILE", config_file.to_str().expect("Failed to get str"), ); - std::env::set_var("SCUF_LOG_LEVEL", "api=info"); + std::env::set_var("SCUF_LOGGING__LEVEL", "api=info"); let config = AppConfig::parse().expect("Failed to parse config"); - assert_eq!(config.log_level, "api=info"); - assert_eq!(config.bind_address, "[::]:8081"); + assert_eq!(config.logging.level, "api=info"); + assert_eq!(config.api.bind_address, "[::]:8081".parse().unwrap()); assert_eq!( - config.database_url, + config.database.uri, "postgres://postgres:postgres@localhost:5433/postgres" ); assert_eq!( diff --git a/backend/api/src/tests/database/global_role.rs b/backend/api/src/tests/database/global_role.rs new file mode 100644 index 00000000..36a0459f --- /dev/null +++ b/backend/api/src/tests/database/global_role.rs @@ -0,0 +1,33 @@ +use crate::database::global_role::Permission; + +#[test] +fn test_has_permission_admin() { + let p = Permission::Admin | Permission::GoLive; + + // Admin has all permissions + assert!(p.has_permission( + Permission::Admin + | Permission::GoLive + | Permission::StreamRecording + | Permission::StreamTranscoding + )); +} + +#[test] +fn test_has_permission_go_live() { + let p = Permission::GoLive; + + // GoLive has GoLive permission + assert!(p.has_permission(Permission::GoLive)); + + // GoLive does not have Admin permission + assert!(!p.has_permission(Permission::Admin)); +} + +#[test] +fn test_has_permission_default() { + let p = Permission::default(); + + // default has no permissions + assert!(p.is_none()); +} diff --git a/backend/api/src/tests/database/mod.rs b/backend/api/src/tests/database/mod.rs new file mode 100644 index 00000000..64697ce6 --- /dev/null +++ b/backend/api/src/tests/database/mod.rs @@ -0,0 +1,2 @@ +mod global_role; +mod user; diff --git a/common/src/tests/types/user.rs b/backend/api/src/tests/database/user.rs similarity index 99% rename from common/src/tests/types/user.rs rename to backend/api/src/tests/database/user.rs index cfbd0bdc..522615ef 100644 --- a/common/src/tests/types/user.rs +++ b/backend/api/src/tests/database/user.rs @@ -1,4 +1,4 @@ -use crate::types::user; +use crate::database::user; #[test] fn test_verify_password() { diff --git a/backend/api/src/tests/dataloader/mod.rs b/backend/api/src/tests/dataloader/mod.rs index 17cc073b..2fd88670 100644 --- a/backend/api/src/tests/dataloader/mod.rs +++ b/backend/api/src/tests/dataloader/mod.rs @@ -1,2 +1,5 @@ mod session; +mod stream_variant; +mod streams; mod user; +mod user_permissions; diff --git a/backend/api/src/tests/dataloader/session.rs b/backend/api/src/tests/dataloader/session.rs index 0dcccaf2..b38399d2 100644 --- a/backend/api/src/tests/dataloader/session.rs +++ b/backend/api/src/tests/dataloader/session.rs @@ -1,9 +1,11 @@ use crate::tests::global::mock_global_state; -use common::types::{session, user}; +use crate::database::{session, user}; +use serial_test::serial; +#[serial] #[tokio::test] -async fn test_user_by_username_loader() { +async fn test_serial_user_by_username_loader() { let (global, _) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") @@ -12,11 +14,11 @@ async fn test_user_by_username_loader() { .unwrap(); let user = sqlx::query_as!(user::Model, - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .fetch_one(&*global.db) .await diff --git a/backend/api/src/tests/dataloader/stream_variant.rs b/backend/api/src/tests/dataloader/stream_variant.rs new file mode 100644 index 00000000..a4243c6b --- /dev/null +++ b/backend/api/src/tests/dataloader/stream_variant.rs @@ -0,0 +1,179 @@ +use crate::tests::global::mock_global_state; + +use crate::database::{stream, stream_variant, user}; +use chrono::Utc; +use serde_json::json; +use serial_test::serial; +use uuid::Uuid; + +#[serial] +#[tokio::test] +async fn test_serial_stream_varariants_by_stream_id_loader() { + let (global, _) = mock_global_state(Default::default()).await; + + sqlx::query!("DELETE FROM users") + .execute(&*global.db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*global.db) + .await + .unwrap(); + let user = + sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", + "admin", + "admin@admin.com", + user::hash_password("admin"), + user::generate_stream_key(), + ) + .fetch_one(&*global.db) + .await + .unwrap(); + + let conn_id = Uuid::new_v4(); + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*global.db).await.unwrap(); + + let variants = vec![ + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-audio".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: Some("aac".to_string()), + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-only".to_string(), + stream_id: s.id, + audio_bitrate: None, + audio_channels: None, + audio_sample_rate: None, + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: None, + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "audio-only".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: None, + video_framerate: None, + video_height: None, + video_width: None, + audio_codec: Some("aac".to_string()), + video_codec: None, + created_at: Utc::now(), + metadata: json!({}), + }, + ]; + + for v in &variants { + sqlx::query!("INSERT INTO stream_variants (id, name, stream_id, audio_bitrate, audio_channels, audio_sample_rate, video_bitrate, video_framerate, video_height, video_width, audio_codec, video_codec, created_at, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,$12, $13, $14)", + v.id, + v.name, + v.stream_id, + v.audio_bitrate, + v.audio_channels, + v.audio_sample_rate, + v.video_bitrate, + v.video_framerate, + v.video_height, + v.video_width, + v.audio_codec, + v.video_codec, + v.created_at, + v.metadata, + ).execute(&*global.db).await.unwrap(); + } + + let loaded = global + .stream_variants_by_stream_id_loader + .load_one(s.id) + .await + .unwrap(); + + assert!(loaded.is_some()); + + let loaded = loaded.unwrap(); + + let audio_video = loaded.iter().find(|v| v.name == "video-audio").unwrap(); + + assert_eq!(audio_video.audio_bitrate, Some(128)); + assert_eq!(audio_video.audio_channels, Some(2)); + assert_eq!(audio_video.audio_sample_rate, Some(44100)); + assert_eq!(audio_video.video_bitrate, Some(12800)); + assert_eq!(audio_video.video_framerate, Some(30)); + assert_eq!(audio_video.video_height, Some(720)); + assert_eq!(audio_video.video_width, Some(1280)); + assert_eq!(audio_video.audio_codec, Some("aac".to_string())); + assert_eq!(audio_video.video_codec, Some("h264".to_string())); + assert_eq!(audio_video.stream_id, s.id); + assert_eq!( + audio_video.created_at.timestamp(), + variants[0].created_at.timestamp() + ); + assert_eq!(audio_video.metadata, variants[0].metadata); + + let video_only = loaded.iter().find(|v| v.name == "video-only").unwrap(); + + assert_eq!(video_only.audio_bitrate, None); + assert_eq!(video_only.audio_channels, None); + assert_eq!(video_only.audio_sample_rate, None); + assert_eq!(video_only.video_bitrate, Some(12800)); + assert_eq!(video_only.video_framerate, Some(30)); + assert_eq!(video_only.video_height, Some(720)); + assert_eq!(video_only.video_width, Some(1280)); + assert_eq!(video_only.audio_codec, None); + assert_eq!(video_only.video_codec, Some("h264".to_string())); + assert_eq!(video_only.stream_id, s.id); + assert_eq!( + video_only.created_at.timestamp(), + variants[1].created_at.timestamp() + ); + assert_eq!(video_only.metadata, variants[1].metadata); + + let audio_only = loaded.iter().find(|v| v.name == "audio-only").unwrap(); + + assert_eq!(audio_only.audio_bitrate, Some(128)); + assert_eq!(audio_only.audio_channels, Some(2)); + assert_eq!(audio_only.audio_sample_rate, Some(44100)); + assert_eq!(audio_only.video_bitrate, None); + assert_eq!(audio_only.video_framerate, None); + assert_eq!(audio_only.video_height, None); + assert_eq!(audio_only.video_width, None); + assert_eq!(audio_only.audio_codec, Some("aac".to_string())); + assert_eq!(audio_only.video_codec, None); + assert_eq!(audio_only.stream_id, s.id); + assert_eq!( + audio_only.created_at.timestamp(), + variants[2].created_at.timestamp() + ); + assert_eq!(audio_only.metadata, variants[2].metadata); +} diff --git a/backend/api/src/tests/dataloader/streams.rs b/backend/api/src/tests/dataloader/streams.rs new file mode 100644 index 00000000..1f667c96 --- /dev/null +++ b/backend/api/src/tests/dataloader/streams.rs @@ -0,0 +1,57 @@ +use crate::tests::global::mock_global_state; + +use crate::database::{stream, user}; +use serial_test::serial; +use uuid::Uuid; + +#[serial] +#[tokio::test] +async fn test_serial_stream_by_id_loader() { + let (global, _) = mock_global_state(Default::default()).await; + + sqlx::query!("DELETE FROM users") + .execute(&*global.db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*global.db) + .await + .unwrap(); + let user = + sqlx::query_as!(user::Model, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", + "admin", + "admin@admin.com", + user::hash_password("admin"), + user::generate_stream_key(), + ) + .fetch_one(&*global.db) + .await + .unwrap(); + + let conn_id = Uuid::new_v4(); + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*global.db).await.unwrap(); + + let loaded = global.stream_by_id_loader.load_one(s.id).await.unwrap(); + + assert!(loaded.is_some()); + + let loaded = loaded.unwrap(); + assert_eq!(loaded.id, s.id); + assert_eq!(loaded.channel_id, user.id); + assert_eq!(loaded.title, "test"); + assert_eq!(loaded.description, "test"); + assert!(!loaded.recorded); + assert!(!loaded.transcoded); + assert_eq!(loaded.ingest_address, "some address"); + assert_eq!(loaded.connection_id, conn_id); +} diff --git a/backend/api/src/tests/dataloader/user.rs b/backend/api/src/tests/dataloader/user.rs index 918d909f..a87722a6 100644 --- a/backend/api/src/tests/dataloader/user.rs +++ b/backend/api/src/tests/dataloader/user.rs @@ -1,9 +1,11 @@ use crate::tests::global::mock_global_state; -use common::types::user; +use crate::database::user; +use serial_test::serial; +#[serial] #[tokio::test] -async fn test_user_by_username_loader() { +async fn test_serial_user_by_username_loader() { let (global, _) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") @@ -12,11 +14,11 @@ async fn test_user_by_username_loader() { .unwrap(); let user = sqlx::query_as!(user::Model, - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .fetch_one(&*global.db) .await @@ -38,8 +40,9 @@ async fn test_user_by_username_loader() { assert_eq!(loaded.created_at, user.created_at); } +#[serial] #[tokio::test] -async fn test_user_by_id_loader() { +async fn test_serial_user_by_id_loader() { let (global, _) = mock_global_state(Default::default()).await; sqlx::query!("DELETE FROM users") @@ -48,11 +51,11 @@ async fn test_user_by_id_loader() { .unwrap(); let user = sqlx::query_as!(user::Model, - "INSERT INTO users(id, username, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING *", - 1, + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", "admin", "admin@admin.com", - user::hash_password("admin") + user::hash_password("admin"), + user::generate_stream_key(), ) .fetch_one(&*global.db) .await diff --git a/backend/api/src/tests/dataloader/user_permissions.rs b/backend/api/src/tests/dataloader/user_permissions.rs new file mode 100644 index 00000000..a6f9f541 --- /dev/null +++ b/backend/api/src/tests/dataloader/user_permissions.rs @@ -0,0 +1,237 @@ +use crate::tests::global::mock_global_state; + +use crate::database::{global_role::Permission, user}; +use serial_test::serial; + +#[serial] +#[tokio::test] +async fn test_serial_permissions_loader() { + let (global, _) = mock_global_state(Default::default()).await; + + sqlx::query!("DELETE FROM users") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_roles") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*global.db) + .await + .unwrap(); + + let user_id = sqlx::query!( + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", + "admin", + "admin@admin.com", + user::hash_password("admin"), + user::generate_stream_key(), + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let admin_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "admin", + "admin", + 1, + Permission::Admin.bits(), + 0, + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "go_live", + "go_live", + 2, + Permission::GoLive.bits(), + 0, + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let no_go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "no_go_live", + "no_go_live", + 3, + 0, + Permission::GoLive.bits(), + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let no_admin_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "no_admin", + "no_admin", + 0, + 0, + Permission::Admin.bits(), + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + admin_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + go_live_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + no_go_live_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants(user_id, global_role_id, created_at) VALUES ($1, $2, $3)", + user_id, + no_admin_role_id, + chrono::Utc::now() + ) + .execute(&*global.db) + .await + .unwrap(); + + let loaded = global + .user_permisions_by_id_loader + .load_one(user_id) + .await + .unwrap(); + assert!(loaded.is_some()); + + let loaded = loaded.unwrap(); + assert_eq!(loaded.permissions, Permission::Admin); + assert_eq!(loaded.user_id, user_id); + + assert_eq!(loaded.roles[0].id, no_admin_role_id); + assert_eq!(loaded.roles[0].name, "no_admin"); + assert_eq!(loaded.roles[0].description, "no_admin"); + assert_eq!(loaded.roles[0].rank, 0); + assert_eq!(loaded.roles[0].allowed_permissions, 0); + assert_eq!(loaded.roles[0].denied_permissions, Permission::Admin); + + assert_eq!(loaded.roles[1].id, admin_role_id); + assert_eq!(loaded.roles[1].name, "admin"); + assert_eq!(loaded.roles[1].description, "admin"); + assert_eq!(loaded.roles[1].rank, 1); + assert_eq!(loaded.roles[1].allowed_permissions, Permission::Admin); + assert_eq!(loaded.roles[1].denied_permissions, 0); + + assert_eq!(loaded.roles[2].id, go_live_role_id); + assert_eq!(loaded.roles[2].name, "go_live"); + assert_eq!(loaded.roles[2].description, "go_live"); + assert_eq!(loaded.roles[2].rank, 2); + assert_eq!(loaded.roles[2].allowed_permissions, Permission::GoLive); + assert_eq!(loaded.roles[2].denied_permissions, 0); + + assert_eq!(loaded.roles[3].id, no_go_live_role_id); + assert_eq!(loaded.roles[3].name, "no_go_live"); + assert_eq!(loaded.roles[3].description, "no_go_live"); + assert_eq!(loaded.roles[3].rank, 3); + assert_eq!(loaded.roles[3].allowed_permissions, 0); + assert_eq!(loaded.roles[3].denied_permissions, Permission::GoLive); +} + +#[serial] +#[tokio::test] +async fn test_serial_permissions_loader_default_role() { + let (global, _) = mock_global_state(Default::default()).await; + + sqlx::query!("DELETE FROM users") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_roles") + .execute(&*global.db) + .await + .unwrap(); + + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*global.db) + .await + .unwrap(); + + let user_id = sqlx::query!( + "INSERT INTO users(username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING id", + "admin", + "admin@admin.com", + user::hash_password("admin"), + user::generate_stream_key(), + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let default_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "default", + "default", + -1, + Permission::GoLive.bits(), + 0, + chrono::Utc::now() + ) + .map(|row| row.id) + .fetch_one(&*global.db) + .await + .unwrap(); + + let loaded = global + .user_permisions_by_id_loader + .load_one(user_id) + .await + .unwrap(); + assert!(loaded.is_some()); + + let loaded = loaded.unwrap(); + assert_eq!(loaded.permissions, Permission::GoLive); + assert_eq!(loaded.user_id, user_id); + + assert_eq!(loaded.roles[0].id, default_role_id); + assert_eq!(loaded.roles[0].name, "default"); + assert_eq!(loaded.roles[0].description, "default"); + assert_eq!(loaded.roles[0].rank, -1); + assert_eq!(loaded.roles[0].allowed_permissions, Permission::GoLive); + assert_eq!(loaded.roles[0].denied_permissions, 0); +} diff --git a/backend/api/src/tests/global/mod.rs b/backend/api/src/tests/global/mod.rs index cf5eb830..24543c3c 100644 --- a/backend/api/src/tests/global/mod.rs +++ b/backend/api/src/tests/global/mod.rs @@ -1,8 +1,9 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use common::{ context::{Context, Handler}, logging, + prelude::FutureTimeout, }; use crate::{config::AppConfig, global::GlobalState}; @@ -14,7 +15,8 @@ pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) dotenvy::dotenv().ok(); - logging::init("api=debug").expect("failed to initialize logging"); + logging::init(&config.logging.level, config.logging.json) + .expect("failed to initialize logging"); let db = Arc::new( sqlx::PgPool::connect(&std::env::var("DATABASE_URL").expect("DATABASE_URL not set")) @@ -22,5 +24,16 @@ pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) .expect("failed to connect to database"), ); - (Arc::new(GlobalState::new(config, db, ctx)), handler) + let rmq = common::rmq::ConnectionPool::connect( + std::env::var("RMQ_URL").expect("RMQ_URL not set"), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .expect("failed to connect to rabbitmq") + .expect("failed to connect to rabbitmq"); + + (Arc::new(GlobalState::new(config, db, rmq, ctx)), handler) } diff --git a/backend/api/src/tests/grpc/api.rs b/backend/api/src/tests/grpc/api.rs new file mode 100644 index 00000000..672cb233 --- /dev/null +++ b/backend/api/src/tests/grpc/api.rs @@ -0,0 +1,1255 @@ +use crate::config::{AppConfig, GrpcConfig}; +use crate::database::{global_role::Permission, user}; +use crate::database::{stream, stream_bitrate_update, stream_event, stream_variant}; +use crate::grpc::pb::scuffle::backend::{ + update_live_stream_request, LiveStreamState, NewLiveStreamRequest, +}; +use crate::grpc::pb::scuffle::types::stream_variant::{AudioSettings, VideoSettings}; +use crate::grpc::pb::scuffle::types::StreamVariant; +use crate::grpc::{self, run}; +use crate::tests::global::mock_global_state; +use chrono::Utc; +use common::grpc::make_channel; +use common::prelude::FutureTimeout; +use serde_json::json; +use serial_test::serial; +use std::time::Duration; +use uuid::Uuid; + +#[serial] +#[tokio::test] +async fn test_serial_grpc_authenticate_invalid_stream_key() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + + // We only want a single resolve attempt, so we set the timeout to 0 + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + let err = client + .authenticate_live_stream(grpc::pb::scuffle::backend::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: "test".to_string(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert_eq!(err.message(), "invalid stream key"); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_authenticate_valid_stream_key() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_roles") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key) VALUES ($1, $1, $2, $3, $4) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "Go Live", + "Allows a user to go live", + 0, + Permission::GoLive.bits(), + 0, + chrono::Utc::now(), + ).map(|r| r.id).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + let resp = client + .authenticate_live_stream(grpc::pb::scuffle::backend::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(resp.code(), tonic::Code::PermissionDenied); + assert_eq!(resp.message(), "user has no permission to go live"); + + sqlx::query!( + "INSERT INTO global_role_grants (user_id, global_role_id) VALUES ($1, $2)", + user.id, + go_live_role_id + ) + .execute(&*db) + .await + .unwrap(); + + let resp = client + .authenticate_live_stream(grpc::pb::scuffle::backend::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }) + .await + .unwrap() + .into_inner(); + + assert!(!resp.record); + assert!(!resp.transcode); + assert!(!resp.stream_id.is_empty()); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_authenticate_valid_stream_key_ext() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_roles") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "Go Live", + "Allows a user to go live", + 0, + (Permission::GoLive | Permission::StreamRecording).bits(), + 0, + chrono::Utc::now(), + ).map(|r| r.id).fetch_one(&*db).await.unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants (user_id, global_role_id) VALUES ($1, $2)", + user.id, + go_live_role_id + ) + .execute(&*db) + .await + .unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + let resp = client + .authenticate_live_stream(grpc::pb::scuffle::backend::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }) + .await + .unwrap() + .into_inner(); + + assert!(resp.record); + assert!(!resp.transcode); + assert!(!resp.stream_id.is_empty()); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_authenticate_valid_stream_key_ext_2() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_roles") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM global_role_grants") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let go_live_role_id = sqlx::query!( + "INSERT INTO global_roles(name, description, rank, allowed_permissions, denied_permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "Go Live", + "Allows a user to go live", + 0, + (Permission::GoLive | Permission::StreamTranscoding).bits(), + 0, + chrono::Utc::now(), + ).map(|r| r.id).fetch_one(&*db).await.unwrap(); + + sqlx::query!( + "INSERT INTO global_role_grants (user_id, global_role_id) VALUES ($1, $2)", + user.id, + go_live_role_id + ) + .execute(&*db) + .await + .unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + let resp = client + .authenticate_live_stream(grpc::pb::scuffle::backend::AuthenticateLiveStreamRequest { + app_name: "test".to_string(), + stream_key: user.get_stream_key(), + ip_address: "127.0.0.1".to_string(), + ingest_address: "127.0.0.1:1234".to_string(), + connection_id: Uuid::new_v4().to_string(), + }) + .await + .unwrap() + .into_inner(); + + assert!(!resp.record); + assert!(resp.transcode); + assert!(!resp.stream_id.is_empty()); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_update_live_stream_state() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let conn_id = Uuid::new_v4(); + + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + { + let timestamp = Utc::now().timestamp() as u64; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::State( + LiveStreamState::Ready as i32 + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.state, stream::State::Ready); + assert_eq!(s.updated_at.unwrap().timestamp() as u64, timestamp); + } + + { + let timestamp = Utc::now().timestamp() as u64; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::State( + LiveStreamState::NotReady as i32 + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.state, stream::State::NotReady); + assert_eq!(s.updated_at.unwrap().timestamp() as u64, timestamp); + } + + { + let timestamp = Utc::now().timestamp() as u64; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::State( + LiveStreamState::Failed as i32 + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.state, stream::State::Failed); + assert_eq!(s.updated_at.unwrap().timestamp() as u64, timestamp); + assert_eq!(s.ended_at.timestamp() as u64, timestamp); + } + + for i in 0..2 { + let timestamp = Utc::now().timestamp() as u64; + + let res = client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::State( + LiveStreamState::Stopped as i32, + )), + }], + }) + .await; + + if i == 0 { + assert!(res.is_err()); + sqlx::query!( + "UPDATE streams SET state = 0, ended_at = $2 WHERE id = $1;", + s.id, + Utc::now() + chrono::Duration::seconds(300) + ) + .execute(&*db) + .await + .unwrap(); + } else { + assert!(res.is_ok()); + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.state, stream::State::Stopped); + assert_eq!(s.updated_at.unwrap().timestamp() as u64, timestamp); + assert_eq!(s.ended_at.timestamp() as u64, timestamp); + } + } + + for i in 0..2 { + let timestamp = Utc::now().timestamp() as u64; + + let res = client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::State( + LiveStreamState::StoppedResumable as i32, + )), + }], + }) + .await; + + if i == 0 { + assert!(res.is_err()); + sqlx::query!( + "UPDATE streams SET state = 0, ended_at = $2 WHERE id = $1;", + s.id, + Utc::now() + chrono::Duration::seconds(300) + ) + .execute(&*db) + .await + .unwrap(); + } else { + assert!(res.is_ok()); + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.state, stream::State::StoppedResumable); + assert_eq!(s.updated_at.unwrap().timestamp() as u64, timestamp); + assert_eq!(s.ended_at.timestamp() as u64, timestamp + 300); + } + } + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_update_live_stream_bitrate() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let conn_id = Uuid::new_v4(); + + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + { + let timestamp = Utc::now().timestamp() as u64; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::Bitrate( + update_live_stream_request::Bitrate { + video_bitrate: 1000, + audio_bitrate: 1000, + metadata_bitrate: 1000 + } + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!( + stream_bitrate_update::Model, + "SELECT * FROM stream_bitrate_updates WHERE stream_id = $1", + s.id, + ) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.audio_bitrate, 1000); + assert_eq!(s.video_bitrate, 1000); + assert_eq!(s.metadata_bitrate, 1000); + assert_eq!(s.created_at.timestamp() as u64, timestamp); + } + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_update_live_stream_event() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let conn_id = Uuid::new_v4(); + + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + { + let timestamp = Utc::now().timestamp() as u64; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::Event( + update_live_stream_request::Event { + level: update_live_stream_request::event::Level::Info.into(), + message: "test - message".to_string(), + title: "test - title".to_string(), + } + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!( + stream_event::Model, + "SELECT * FROM stream_events WHERE stream_id = $1", + s.id, + ) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.level, stream_event::Level::Info); + assert_eq!(s.message, "test - message"); + assert_eq!(s.title, "test - title"); + assert_eq!(s.created_at.timestamp() as u64, timestamp); + } + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_update_live_stream_variants() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let conn_id = Uuid::new_v4(); + + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + { + let timestamp = Utc::now().timestamp() as u64; + + let variants = vec![ + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-audio".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: Some("aac".to_string()), + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-only".to_string(), + stream_id: s.id, + audio_bitrate: None, + audio_channels: None, + audio_sample_rate: None, + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: None, + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "audio-only".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: None, + video_framerate: None, + video_height: None, + video_width: None, + audio_codec: Some("aac".to_string()), + video_codec: None, + created_at: Utc::now(), + metadata: json!({}), + }, + ]; + + assert!(client + .update_live_stream(grpc::pb::scuffle::backend::UpdateLiveStreamRequest { + connection_id: conn_id.to_string(), + stream_id: s.id.to_string(), + updates: vec![update_live_stream_request::Update { + timestamp, + update: Some(update_live_stream_request::update::Update::Variants( + update_live_stream_request::Variants { + variants: variants + .iter() + .map(|v| { + let audio_settings = + v.audio_bitrate.map(|audio_bitrate| AudioSettings { + bitrate: audio_bitrate as u32, + channels: v.audio_channels.unwrap() as u32, + sample_rate: v.audio_sample_rate.unwrap() as u32, + codec: v.audio_codec.clone().unwrap(), + }); + + let video_settings = + v.video_bitrate.map(|video_bitrate| VideoSettings { + bitrate: video_bitrate as u32, + framerate: v.video_framerate.unwrap() as u32, + height: v.video_height.unwrap() as u32, + width: v.video_width.unwrap() as u32, + codec: v.video_codec.clone().unwrap(), + }); + + StreamVariant { + name: v.name.clone(), + id: v.id.to_string(), + metadata: v.metadata.to_string(), + audio_settings, + video_settings, + } + }) + .collect(), + } + )), + }] + }) + .await + .is_ok()); + + let s = sqlx::query_as!( + stream_variant::Model, + "SELECT * FROM stream_variants WHERE stream_id = $1", + s.id, + ) + .fetch_all(&*db) + .await + .unwrap(); + + let v = s.iter().find(|v| v.name == "video-audio").unwrap(); + assert_eq!(v.id, variants[0].id); + assert_eq!(v.audio_bitrate, Some(128)); + assert_eq!(v.audio_channels, Some(2)); + assert_eq!(v.audio_sample_rate, Some(44100)); + assert_eq!(v.video_bitrate, Some(12800)); + assert_eq!(v.video_framerate, Some(30)); + assert_eq!(v.video_height, Some(720)); + assert_eq!(v.video_width, Some(1280)); + assert_eq!(v.audio_codec, Some("aac".to_string())); + assert_eq!(v.video_codec, Some("h264".to_string())); + assert_eq!(v.metadata, json!({})); + assert_eq!(v.created_at.timestamp(), timestamp as i64); + + let v = s.iter().find(|v| v.name == "video-only").unwrap(); + assert_eq!(v.id, variants[1].id); + assert_eq!(v.audio_bitrate, None); + assert_eq!(v.audio_channels, None); + assert_eq!(v.audio_sample_rate, None); + assert_eq!(v.video_bitrate, Some(12800)); + assert_eq!(v.video_framerate, Some(30)); + assert_eq!(v.video_height, Some(720)); + assert_eq!(v.video_width, Some(1280)); + assert_eq!(v.audio_codec, None); + assert_eq!(v.video_codec, Some("h264".to_string())); + assert_eq!(v.metadata, json!({})); + assert_eq!(v.created_at.timestamp(), timestamp as i64); + + let v = s.iter().find(|v| v.name == "audio-only").unwrap(); + assert_eq!(v.id, variants[2].id); + assert_eq!(v.audio_bitrate, Some(128)); + assert_eq!(v.audio_channels, Some(2)); + assert_eq!(v.audio_sample_rate, Some(44100)); + assert_eq!(v.video_bitrate, None); + assert_eq!(v.video_framerate, None); + assert_eq!(v.video_height, None); + assert_eq!(v.video_width, None); + assert_eq!(v.audio_codec, Some("aac".to_string())); + assert_eq!(v.video_codec, None); + assert_eq!(v.metadata, json!({})); + assert_eq!(v.created_at.timestamp(), timestamp as i64); + } + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[serial] +#[tokio::test] +async fn test_serial_grpc_new_live_stream() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let db = global.db.clone(); + sqlx::query!("DELETE FROM users") + .execute(&*db) + .await + .unwrap(); + sqlx::query!("DELETE FROM streams") + .execute(&*db) + .await + .unwrap(); + + let user = sqlx::query_as!(user::Model, + "INSERT INTO users (username, display_name, email, password_hash, stream_key, stream_recording_enabled, stream_transcoding_enabled) VALUES ($1, $1, $2, $3, $4, true, true) RETURNING *", + "test", + "test@test.com", + user::hash_password("test"), + user::generate_stream_key(), + ).fetch_one(&*db).await.unwrap(); + + let conn_id = Uuid::new_v4(); + + let s = sqlx::query_as!(stream::Model, + "INSERT INTO streams (channel_id, title, description, recorded, transcoded, ingest_address, connection_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + user.id, + "test", + "test", + false, + false, + "some address", + conn_id, + ).fetch_one(&*db).await.unwrap(); + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::scuffle::backend::api_client::ApiClient::new(channel); + + let variants = vec![ + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-audio".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: Some("aac".to_string()), + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "video-only".to_string(), + stream_id: s.id, + audio_bitrate: None, + audio_channels: None, + audio_sample_rate: None, + video_bitrate: Some(12800), + video_framerate: Some(30), + video_height: Some(720), + video_width: Some(1280), + audio_codec: None, + video_codec: Some("h264".to_string()), + created_at: Utc::now(), + metadata: json!({}), + }, + stream_variant::Model { + id: Uuid::new_v4(), + name: "audio-only".to_string(), + stream_id: s.id, + audio_bitrate: Some(128), + audio_channels: Some(2), + audio_sample_rate: Some(44100), + video_bitrate: None, + video_framerate: None, + video_height: None, + video_width: None, + audio_codec: Some("aac".to_string()), + video_codec: None, + created_at: Utc::now(), + metadata: json!({}), + }, + ]; + + let response = client + .new_live_stream(NewLiveStreamRequest { + old_stream_id: s.id.to_string(), + variants: variants + .iter() + .map(|v| { + let audio_settings = v.audio_bitrate.map(|audio_bitrate| AudioSettings { + bitrate: audio_bitrate as u32, + channels: v.audio_channels.unwrap() as u32, + sample_rate: v.audio_sample_rate.unwrap() as u32, + codec: v.audio_codec.clone().unwrap(), + }); + + let video_settings = v.video_bitrate.map(|video_bitrate| VideoSettings { + bitrate: video_bitrate as u32, + framerate: v.video_framerate.unwrap() as u32, + height: v.video_height.unwrap() as u32, + width: v.video_width.unwrap() as u32, + codec: v.video_codec.clone().unwrap(), + }); + + StreamVariant { + name: v.name.clone(), + id: v.id.to_string(), + metadata: v.metadata.to_string(), + audio_settings, + video_settings, + } + }) + .collect(), + }) + .await + .unwrap() + .into_inner(); + + let s = sqlx::query_as!(stream::Model, "SELECT * FROM streams WHERE id = $1", s.id,) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.channel_id, user.id); + assert_eq!(s.title, "test"); + assert_eq!(s.description, "test"); + assert!(!s.recorded); + assert!(!s.transcoded); + assert_eq!(s.ingest_address, "some address"); + assert_eq!(s.connection_id, conn_id); + assert_eq!(s.state, stream::State::Stopped); + + let stream_id = Uuid::parse_str(&response.stream_id).unwrap(); + + let s = sqlx::query_as!( + stream::Model, + "SELECT * FROM streams WHERE id = $1", + stream_id, + ) + .fetch_one(&*db) + .await + .unwrap(); + + assert_eq!(s.channel_id, user.id); + assert_eq!(s.title, "test"); + assert_eq!(s.description, "test"); + assert!(!s.recorded); + assert!(!s.transcoded); + assert_eq!(s.ingest_address, "some address"); + assert_eq!(s.connection_id, conn_id); + assert_eq!(s.state, stream::State::NotReady); + + let s = sqlx::query_as!( + stream_variant::Model, + "SELECT * FROM stream_variants WHERE stream_id = $1", + stream_id, + ) + .fetch_all(&*db) + .await + .unwrap(); + + let v = s.iter().find(|v| v.name == "video-audio").unwrap(); + assert_eq!(v.id, variants[0].id); + assert_eq!(v.audio_bitrate, Some(128)); + assert_eq!(v.audio_channels, Some(2)); + assert_eq!(v.audio_sample_rate, Some(44100)); + assert_eq!(v.video_bitrate, Some(12800)); + assert_eq!(v.video_framerate, Some(30)); + assert_eq!(v.video_height, Some(720)); + assert_eq!(v.video_width, Some(1280)); + assert_eq!(v.audio_codec, Some("aac".to_string())); + assert_eq!(v.video_codec, Some("h264".to_string())); + assert_eq!(v.metadata, json!({})); + + let v = s.iter().find(|v| v.name == "video-only").unwrap(); + assert_eq!(v.id, variants[1].id); + assert_eq!(v.audio_bitrate, None); + assert_eq!(v.audio_channels, None); + assert_eq!(v.audio_sample_rate, None); + assert_eq!(v.video_bitrate, Some(12800)); + assert_eq!(v.video_framerate, Some(30)); + assert_eq!(v.video_height, Some(720)); + assert_eq!(v.video_width, Some(1280)); + assert_eq!(v.audio_codec, None); + assert_eq!(v.video_codec, Some("h264".to_string())); + assert_eq!(v.metadata, json!({})); + + let v = s.iter().find(|v| v.name == "audio-only").unwrap(); + assert_eq!(v.id, variants[2].id); + assert_eq!(v.audio_bitrate, Some(128)); + assert_eq!(v.audio_channels, Some(2)); + assert_eq!(v.audio_sample_rate, Some(44100)); + assert_eq!(v.video_bitrate, None); + assert_eq!(v.video_framerate, None); + assert_eq!(v.video_height, None); + assert_eq!(v.video_width, None); + assert_eq!(v.audio_codec, Some("aac".to_string())); + assert_eq!(v.video_codec, None); + assert_eq!(v.metadata, json!({})); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/backend/api/src/tests/grpc/certs/ca.ec.crt b/backend/api/src/tests/grpc/certs/ca.ec.crt new file mode 100644 index 00000000..82ec2056 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/ca.ec.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBbDCCARKgAwIBAgIUD7lIwYJfD4pXzc5Hmka6YW8ZaPkwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQyNTA3 +NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE/Z1nx+SdBH3DEkDJp8meqPghyi8+Zte19nT1a0j9F9rs0Rp3JFqqg19k +3rnk4NhEOpest8oXdxGdGuN9SeAqp6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFEDW3iY//ykVWPuZUP6031FUkADEMAoGCCqG +SM49BAMCA0gAMEUCIGF3DQXktKgLsbG9qAJc5HMmaIn6sslHDmeCXhoHBBnCAiEA +2Cr7XwDhb0G/CeoQFFqN3DqIqdcF9Nx+xD7bq9cmcsQ= +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/ca.ec.key b/backend/api/src/tests/grpc/certs/ca.ec.key new file mode 100644 index 00000000..c88a9c62 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/ca.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgP2JTKPru+7n9VssT +ulAKqEhuXNY6qT53Al0QPMF3akahRANCAAT9nWfH5J0EfcMSQMmnyZ6o+CHKLz5m +17X2dPVrSP0X2uzRGnckWqqDX2TeueTg2EQ6l6y3yhd3EZ0a431J4Cqn +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/certs/ca.ini b/backend/api/src/tests/grpc/certs/ca.ini new file mode 100644 index 00000000..bf362da6 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/ca.ini @@ -0,0 +1,13 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +# Since this is a CA, the key usage is critical +x509_extensions = v3_ca + +[v3_ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[dn] +CN = 127.0.0.1 diff --git a/backend/api/src/tests/grpc/certs/ca.rsa.crt b/backend/api/src/tests/grpc/certs/ca.rsa.crt new file mode 100644 index 00000000..054ad430 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/ca.rsa.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+DCCAeCgAwIBAgIUXqZUdmW4azF3HUzOBmLvHdVFeEIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQy +NTA3NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA2StIP6B/kMC2Bj/FDbod+Vy7NRMR7xmKV1H3jJW68Ect +tjYtAdbMvUpG83hxzZYPJtf7XxT6ZLqzoyTcINMUTMjmKJIL6G7tnw3h38SjjRxN +rkc2GF0a7wF1XKKnL2QBkbIp275j1Sx10BJ1JjY8d9Xpb7DpRy9AhVqpBq6yQu0K +9lYQ0tBKHCq4xiX6cACnh0onIXyoh//NhFrYwkC5092I2M+qHjCT4lxUPj/5uz85 +cFYUM19FaIpr6AwlLwij80Sgiaa9OGvHti7irh2wZZ0QO8nka8Rt6QvjZDW0Ceni +qi7UvLVj23pPCONGiTlfL+moYxBP4DJC7ookpbjEWQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUaTUP1AzqvhhKX6br +P+b+4V+J7U4wDQYJKoZIhvcNAQELBQADggEBAAKFV6N/NeXTagXq8xJKn9+7LLQk +TuxDYAvSguDvDHKZdfi36w+UntGtTMnvDinQhSkagYXIvBbQCA9ZsNnv3UsZ/atc +uMPpN7qoM3MVmoNi1/T9h5ZBFmIXjSl7aY/vRWfogt0k6IRF4fU9RR5Vc7gG77aH +OvXeJAvW6swgg36GDLgvwizzMJECdGLRnWAkJ4uHp/PUXbY8XKKi3eooNjgXjM2m +xMPkev3mSL6nBYqQc3Jtw5r9pqbc/3bgu3w3Wb//yuUSzCi2a+PL2jN0SfDMUgKx +X/CN8Dd4sewYpC5gBqW29W2cDSmDFgy5s6PGyxCOBwNhXlHjyraRRKcgBfc= +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/ca.rsa.key b/backend/api/src/tests/grpc/certs/ca.rsa.key new file mode 100644 index 00000000..25fe34f9 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/ca.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZK0g/oH+QwLYG +P8UNuh35XLs1ExHvGYpXUfeMlbrwRy22Ni0B1sy9SkbzeHHNlg8m1/tfFPpkurOj +JNwg0xRMyOYokgvobu2fDeHfxKONHE2uRzYYXRrvAXVcoqcvZAGRsinbvmPVLHXQ +EnUmNjx31elvsOlHL0CFWqkGrrJC7Qr2VhDS0EocKrjGJfpwAKeHSichfKiH/82E +WtjCQLnT3YjYz6oeMJPiXFQ+P/m7PzlwVhQzX0VoimvoDCUvCKPzRKCJpr04a8e2 +LuKuHbBlnRA7yeRrxG3pC+NkNbQJ6eKqLtS8tWPbek8I40aJOV8v6ahjEE/gMkLu +iiSluMRZAgMBAAECggEAJctUDAq1GK6JHyZK93wcClE6nV5/wQJLYq33rIZEXdut +V2gvRgIpaIn8NhQQjixe364355DBkPUzHSHlk2rYvhI6h/X+z4k6nnMui6BvrDew +RzPKdMwDS3QQBjqiaOt5IG+GvGDyg990c2066RcIR/y43wDFYGeXTX39K5YMnPvM +00Rnj3gNiqjiOKTxyyx3w0AZ/vrnBStuhsOekUjjPXm5vMGMJrQUUlcCUVLIwVhE +TTiu740yh4dpIRLwiYpEZZ69Ag2wlY+qCOPltjoDdDSaR86oFSx35d13vDcWThc1 +ve4NRbCLQCYsdRvc2HiHEmTDvZTBbO8k9AxGONxrBwKBgQDx73teWFtlWmKep6kn +DMcEFFt1ichYc5USmxFpNAHm+pCISGk1lElNU7cZ/0WMuv7xDjMsMEzqDKHZtVPm +Azb5dr0qTWij6tEmRXVoZIbCmVyak/hrUYKVlKnGBNxix/WanvUIknmBKo5kHcq8 +h3Sd+HUNit/kVYGqoi27V71mswKBgQDlyzkg48GZpnHnSoKm1LyHnKOKkdCSSrO0 +5xs8Yeq7UWgLRVkRlTjOwdXz5/pYcCyVbVkekj9TARiZupdCyQqlEYShgNp7w9zg +NRmesOOZGHatMs/wIkTfoMpFG5cqPmKU2TvEps89hd+Gg52kDBXFKFS9oICd9fzx +9oZmhLNOwwKBgCylYwDQEV2sxlI84mxAYWGRWCdim8Qm4DWkxBvD6y3yw0VDB5dJ +nBVXA40anH0R7QYS9sKKz0bJufxxB+CEa1qx3Mq3qj3FkX8chkQTeQLkRkCIWemE +CzMLUiEmuHzKJbq45sMENMPvVIOJM+aCoLSeKwuquxJp7RnN/954nI0dAoGBANJm +fspUf2EV+1jQ6kuioXRxwXQRCq3H5D8RE+j3ppsYcHFRb7ofrUHyTNnkX142Zzvy +QRUyxvRTHpkzNWga97ooDg5qEqIbtdM8C1c3k00MDy2KRsYSOomfiVQ5bPFq6Yxs +UsM+EKa+OunI/L/FqPE6ekyd9uWq440QMgMQIbVNAoGBAJEHM0k5ixOy7dddp8HL +g1fCMSG97OP9RvAokJ4IoxYfsN5UrIyzTMEl+X48wr60x4CZ4ujfWJjCf+T0BlOT +ySWJmL/UENgcXXJT9o0r5zDsaHzl50TTqzc2e88bEZugMZtI4irsYKdTNbpMAB4t +s8iLHThB6pH8Zm+l9uIJ+5jS +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/certs/client.ec.crt b/backend/api/src/tests/grpc/certs/client.ec.crt new file mode 100644 index 00000000..d559430b --- /dev/null +++ b/backend/api/src/tests/grpc/certs/client.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtjCCAV2gAwIBAgIUWdzGQA6hGcxsRcr/c7Zt0WyEoPkwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQyNTA3 +NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE7PXNCQBhW4UNHRlJYUWNtarw3DJI4L3jc/q5nHBCCm+fe29VrKw4kn3f +Lsy3671/l7iCzncuaADxjZpHYhJCP6OBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFD7BGHJdZGtp2Ax/2WfWnZiWplBHMB8GA1UdIwQYMBaA +FEDW3iY//ykVWPuZUP6031FUkADEMAoGCCqGSM49BAMCA0cAMEQCIBZnmCIL/cSz +wA1Cm7H7umLXddgwkApe/LzW/jjGkBZhAiAV4XGwZ/r3RjnprZLGykE3EDh9AQF4 +k8FVihOBnbTepQ== +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/client.ec.key b/backend/api/src/tests/grpc/certs/client.ec.key new file mode 100644 index 00000000..7f6040c1 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/client.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggQHoqFAca1SpwuZK +61I7pV/UM27GyaDlqeSRpmKQyaOhRANCAATs9c0JAGFbhQ0dGUlhRY21qvDcMkjg +veNz+rmccEIKb597b1WsrDiSfd8uzLfrvX+XuILOdy5oAPGNmkdiEkI/ +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/certs/client.ini b/backend/api/src/tests/grpc/certs/client.ini new file mode 100644 index 00000000..01771e66 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/client.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_client + +[v3_client] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/backend/api/src/tests/grpc/certs/client.rsa.crt b/backend/api/src/tests/grpc/certs/client.rsa.crt new file mode 100644 index 00000000..25eb02fe --- /dev/null +++ b/backend/api/src/tests/grpc/certs/client.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUaMd1mBrftVLT5LEX6x2F5w6nu0EwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQy +NTA3NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtpY9Mz1nNDT5tV7SOQhoeF90TcL80A8XN/+svXSWOIZI +xsuScR9GyMbmHuxCNWr1WGm/rYAE9lXLMQBVGR5DCFhriPIhFXV96Bk/mvF4FnTt +kMa63X1SrjwMpjc3PiQFwvo8IZrpb3Scy+npIow4LWeDt2QhRfoFV/OfKFcfe5lw +KrCCD3ciX8GWV3pSKoRpKwYhYa9uG6YmRcTkfJSOEE9Mwf37qDhmJBZ4LONKR+wA +2D0aHRSL2h5PnVi3g+09+PpNyR4YIVCTUk14Bkra/ArRIx+y9kbBFc3ScS1Qws2w +b9KULV9G11c2ZU9uS/JoLPExwHPYr9uhJUbyGlx8gwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU54Z9d3V0fBN0sNuNjKnkSmHS +MMcwHwYDVR0jBBgwFoAUaTUP1AzqvhhKX6brP+b+4V+J7U4wDQYJKoZIhvcNAQEL +BQADggEBAMbwivHIEyfsiGcstppemgkRZQPo5nrHPVp2rS7DZSXeNQn8rimwKBAc +DKcR3K7LAc2iBY3lxlhl7UiELpDxxJkRiH0wCTcogXiOz4Dbavm+zVUmcp73A3GT +AfRmmyE4hhB0GPFHEAgWzQ6NX4m8/hNRHHEu4UWHLbR70eoFcpN1ZQ/ju94O5XWp +0FzaB/EVMG3dq0gD2YjAJud/v8+fZ/dMws7xPtXc+UdoIBNBG9BUgUYFB03aU04h +GdXK/biPc3H45Cn3o/tT+tAZj5D63wqGLsERsDaw0Zndi4CjVMVAcb41o8tx9dwq +7V918+QYCiKL75E44jAE57JdKDu/rJM= +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/client.rsa.key b/backend/api/src/tests/grpc/certs/client.rsa.key new file mode 100644 index 00000000..03ea9f00 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/client.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC2lj0zPWc0NPm1 +XtI5CGh4X3RNwvzQDxc3/6y9dJY4hkjGy5JxH0bIxuYe7EI1avVYab+tgAT2Vcsx +AFUZHkMIWGuI8iEVdX3oGT+a8XgWdO2QxrrdfVKuPAymNzc+JAXC+jwhmulvdJzL +6ekijDgtZ4O3ZCFF+gVX858oVx97mXAqsIIPdyJfwZZXelIqhGkrBiFhr24bpiZF +xOR8lI4QT0zB/fuoOGYkFngs40pH7ADYPRodFIvaHk+dWLeD7T34+k3JHhghUJNS +TXgGStr8CtEjH7L2RsEVzdJxLVDCzbBv0pQtX0bXVzZlT25L8mgs8THAc9iv26El +RvIaXHyDAgMBAAECggEAD+94c4XMmxVAi5ObnDF5EYBrOyb+2TEBlF6ZKe1uxIvC +KeK2Hjwqxl0toM2uUNEyWb9Cl/1QJMIdU/wbPgnF5rv1oePxmHJ2Eaq0DwbVT0hw +1eDlidOYde9Bv12auBUWxHFojBpKfPLCUiQd6wKUtWNcCJNE01tCpu4/s5qe+2XY +fbwyTr3ECh+4ykOe700FkRXvOp6g2MCnYed9UX6R6QnFba4usAYB/6r+caAQLuFU +xkUG0wtgwc3Y5e4ARZfm+jLSk1n0QqmBtRCar+yCZCBv+alvyjUi/6ckMVtKQOls +QmTyVE/0RLJ+ouyxKUSwW+dYgLcGLEtvNPnWD3fIKQKBgQD7qrInLcBnEmGUpuBV +w6e8uz6NjYLnJiFYXuL5R/egdOsZWmN0bU0G4RlYhSlNvtmnJbunkXSxVcUvld/4 +yslrIyTIefp9r0odel8jp0RlGcFSEiX5Jd2lZgwSSEaINfeVLdymYaHT3bG+OaG+ +Fg+YfSZDTpA109nAjbQEHIMZ1wKBgQC5uwz9Ev8ocTM6DqTe7/sdOA/Rmq1A9dPb +UBo/qY7/5zlEX8nh46hDRHK7CDVc2siWtbE5wTmeuQJBq2An0kbJ9cEvNQf2AhcF +jxIjHXTn/D6pH9NEunJ9KIyYa5gpwwiOwZ2i1aLx68zeCUgWJn8QpaVoMtbOD/Om +KDncqoKVNQKBgEYM813YbE/EXAkGkjcNKOltrTG1jBRPFsUEVGVS1OiC4tXBxSEp +M2GQ0n9DQYX0c50E8cDoyfR6jVJ7g0Y7G+mIdLqgyUqmuhiOcWD5NB54FsmDZ7J8 +Cb/6Ma39FDTh4LJTe7GMR4Ezmj/6xrykY17spvPhMF7rQxdH2i2yygMtAoGAbPSM +RBJW2TS/JnncwmPp3lQ7A21H4enUZL3zCBHCL+FHORMwbXGODhgjbhD8rQIz1iW0 +V/tAgBpsdnXArNuwZ13h/H2RtAG526r4qtzm7giOAc0HtTPjYgTdpbTIyQb/CDAQ +96t4MY9w2Xh8b3IZ7HwgMqZQWQYGmxzSBN+KR1kCgYAT/lNpI0ronU9Ag07IERk3 +/okG9FiICDl9hrm/7M3xApbAO3qY6FGh3JXjUpi3eS2nDRmd2ChgBKV6XC1cjeJF +52EIcUM0Qw93iHlJ500NipOO3gxkvm5uMSji+OG3WUW2V/TGrJugR8fH7ndIJuti +i9gnWIcZg0gNKTrfd/Z+0g== +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/certs/generate.sh b/backend/api/src/tests/grpc/certs/generate.sh new file mode 100755 index 00000000..77554790 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/generate.sh @@ -0,0 +1,23 @@ +openssl genrsa -out ca.rsa.key 2048 +openssl genrsa -out server.rsa.key 2048 +openssl genrsa -out client.rsa.key 2048 + +openssl req -x509 -sha256 -days 365 -nodes -key ca.rsa.key -config ca.ini -out ca.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key server.rsa.key -config server.ini -out server.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key client.rsa.key -config client.ini -out client.rsa.crt + +openssl ecparam -name prime256v1 -genkey -noout -out ca.ec.key +openssl ecparam -name prime256v1 -genkey -noout -out server.ec.key +openssl ecparam -name prime256v1 -genkey -noout -out client.ec.key + +openssl pkcs8 -topk8 -nocrypt -in ca.ec.key -out ca.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in server.ec.key -out server.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in client.ec.key -out client.ec.key.pem + +mv ca.ec.key.pem ca.ec.key +mv server.ec.key.pem server.ec.key +mv client.ec.key.pem client.ec.key + +openssl req -x509 -sha256 -days 365 -nodes -key ca.ec.key -config ca.ini -out ca.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key server.ec.key -config server.ini -out server.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key client.ec.key -config client.ini -out client.ec.crt \ No newline at end of file diff --git a/backend/api/src/tests/grpc/certs/server.ec.crt b/backend/api/src/tests/grpc/certs/server.ec.crt new file mode 100644 index 00000000..15dc78b7 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/server.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBuDCCAV2gAwIBAgIUHlnL3L9grMgQlMCTMIYI8VqIqC0wCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQyNTA3 +NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEHpdp0sq/kN5TyTYYpVoxi9nZ7nEaW3wE4siqU4H62N7nySTqTFC/RqKm +zyhrsMKQe0h4b8R4PS+oj/YLY3U0WKOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFNuM6maKVUzmfUp11LFR6dSjjRjIMB8GA1UdIwQYMBaA +FEDW3iY//ykVWPuZUP6031FUkADEMAoGCCqGSM49BAMCA0kAMEYCIQDYME9Gv8mm +yD1v9zHmig8j0yIN/u7oQqK1CtD8Q9kTjgIhAK+yPVwLb2HLzitQSiNkwssjTO55 +bFZFkRpQt5SJERFY +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/server.ec.key b/backend/api/src/tests/grpc/certs/server.ec.key new file mode 100644 index 00000000..e4691e9b --- /dev/null +++ b/backend/api/src/tests/grpc/certs/server.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJlXWMaPnYbDFU3ZT +NWA3y3jYC0MbRjdV1pDdJb8/PjKhRANCAAQel2nSyr+Q3lPJNhilWjGL2dnucRpb +fATiyKpTgfrY3ufJJOpMUL9GoqbPKGuwwpB7SHhvxHg9L6iP9gtjdTRY +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/certs/server.ini b/backend/api/src/tests/grpc/certs/server.ini new file mode 100644 index 00000000..34331807 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/server.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_server + +[v3_server] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/backend/api/src/tests/grpc/certs/server.rsa.crt b/backend/api/src/tests/grpc/certs/server.rsa.crt new file mode 100644 index 00000000..cb501436 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/server.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUQKWD2OUEgRQOZOu5JGFMUviu5gcwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDYwN1oXDTI0MDQy +NTA3NDYwN1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAqe5/bibogtPbqRVLwRrIPWs/uj8fjEFkAv7NQJDcYrY8 +Oe7OH8jzsPd+4/bTYLPCsvI+1qslIWUlvprNfYlXflLiKvNZZ6ahZG5uslj6Hg/I +0mUD43m+mJDpfrti13CTHC7a+i71SWEBgaSWfIK0rN8iqNu4NudR54uWU8USIDyK +8Q2oLTtV4ufOGER3mmEG1IQmuWoCbG/yenNBufH8AFiov/PK1gecOPMeWggKhokB +hZgSCJiKK7ROhY3kBiopHv1dxFHnvLoDAM/tDgTi7WcMwlKeLQ9i3a5z2GzM0PTF +l5FC+haIv9tQmGbtdxvmybl9MD6yKuvYNGQIoM0k5QIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUYCk0gGYAR1kqhuebLop9IPij +9yAwHwYDVR0jBBgwFoAUaTUP1AzqvhhKX6brP+b+4V+J7U4wDQYJKoZIhvcNAQEL +BQADggEBAJV2pwDkvxZGxr5WS5x6PzlekhhhOiSP7BLC30VIN2X+NBZXZ9fYvOfg +GRLF+CWwErInJafFi3BRIsww3y//JVbChBbZMTxZj7IrrxP7A7TPZ17m1M22thO8 +QB8Qk4hMG2zvVH68eVi1ZYecNnH/TtTEXB4Y//nMmOyi6pymJ4SVWOV/320r8xq4 +N0cAlL4ubARbxPkzKIdODJ7F7ZQjqAkn1z1cAdGxSUpjxBI7p86IvI3MxucCIcpv +gSTFmCTAp7i2Dld72jodVo5WbjLxvuvgGfdseDjhhbsQh7Bn7o5KITOJ7HpxBCda +eGK1B7RKkTiqdG/ZSrVj6k9JTzyS3kg= +-----END CERTIFICATE----- diff --git a/backend/api/src/tests/grpc/certs/server.rsa.key b/backend/api/src/tests/grpc/certs/server.rsa.key new file mode 100644 index 00000000..4bb934d1 --- /dev/null +++ b/backend/api/src/tests/grpc/certs/server.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCp7n9uJuiC09up +FUvBGsg9az+6Px+MQWQC/s1AkNxitjw57s4fyPOw937j9tNgs8Ky8j7WqyUhZSW+ +ms19iVd+UuIq81lnpqFkbm6yWPoeD8jSZQPjeb6YkOl+u2LXcJMcLtr6LvVJYQGB +pJZ8grSs3yKo27g251Hni5ZTxRIgPIrxDagtO1Xi584YRHeaYQbUhCa5agJsb/J6 +c0G58fwAWKi/88rWB5w48x5aCAqGiQGFmBIImIortE6FjeQGKike/V3EUee8ugMA +z+0OBOLtZwzCUp4tD2LdrnPYbMzQ9MWXkUL6Foi/21CYZu13G+bJuX0wPrIq69g0 +ZAigzSTlAgMBAAECggEAD5z7tV260EZx8MulnbT9v/LqNI0XM3ZQn5vUtQF6VlGD +IBmKc84tYc2jqYNksYZitblfP68S5soZ2TT0+3tSgCdSY3rfdJARVR52akmVlYyC +uZ4RaOWnNvJdmcjS0JOl2JmPghwtalQQ5N3/+6mwuw93akdh2h2P33PqWIELZM20 +7HKjvJ5kxfGFI56/0cft3lzmbFZyPZSa5aXwDq82X/7cLFr1pZhlCNZnbNr0pyG+ +BiIH7ZeP3caJ3lwhHXdVdsVV2IXrWFQ6qVIvfLBmoRGn4SsSlSl3IlsKOehP0eAW +TubbKsgoZTpB12JyNXIM+2ssFLN0e/JtuhPQzzygMwKBgQDRdNsOM8WKe1XlgkCK +EEH0+4lNhKIc6KFP6IqSPCywPAGDhlkSej3LFbS1kHe4QZsXW5OhFk8LbuNLoPBE +yyD9qKBkuMJR5Xad6tN6uP/7FJuAZusu+7jl+QpgYxMv+GrTo3M6QUuPcG6lOSOd +/pvIvC+NNxIX8No0DbXbQMRx0wKBgQDPsTtJrNgB2eDlik6xIBnwvN3R/WlXY2r2 +DqyaogCvml5ofqnKmEjSrfh7vNtmvHZ8P6ehPMs6s0ap31Kgtuy7DM8DNkw6GwyY +JLlNqzjCdPAnwR8gyU2h3fAAy/4cuJJwsHLjxWJx4sBbHsyK7nzpH7UGkzktxua8 +o5telU6jZwKBgQCO7N5NYqZ5SI/kfGztyQo40Stv6gF1GIh6roNgJg+YclnWFebR +5PgljDozatFGuf3KgoLKeR6W/qO7B6bsSm/IpzhLgoeWuq2mNIb6RyLlgbpac+An +vzz8MGQUQYbmRO0gXXhTWBrnViEqPUNAnGxRHZiVE+8UxxUeT/y4EAn8YQKBgQCY +p6H2Mw7JvYUp8hCI7Blk8szvzZ0h2DcECCEhvzVV3NbLY14VRP0xrSFYgaWZy6gj +Bv6E6pRN3vtvXG/1JL63dWCq8bvxcXQ+V6/DwLgFZcIm1jG0/YEMGn6Pd2CdZ6Rr +I6YueCQ1pP7Rer/I1iYFi4KZBJkgZnOt72sBiCi2vQKBgGmAiVd9RBBecM4fakpB +8H5w2VGm/OEWHy67WWYXi9VpX+j5ovTmERs+q6wo0Qe2ZRyJTB0KyUNiyRqwdCwI ++XmyA97naWoFnRtYwsspm1Q7B8QOfXf0nFQkWJMJamvmYNzEQV3X9HFwIJ7xiz9i +UbTOC+sRqOm9dYHfNn12Us93 +-----END PRIVATE KEY----- diff --git a/backend/api/src/tests/grpc/health.rs b/backend/api/src/tests/grpc/health.rs new file mode 100644 index 00000000..0d2b62aa --- /dev/null +++ b/backend/api/src/tests/grpc/health.rs @@ -0,0 +1,106 @@ +use common::grpc::make_channel; +use common::prelude::FutureTimeout; +use std::time::Duration; + +use crate::config::{AppConfig, GrpcConfig}; +use crate::grpc::{self, run}; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_health_check() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::health::health_client::HealthClient::new(channel); + let resp = client + .check(grpc::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + grpc::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_health_watch() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = grpc::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .watch(grpc::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + + let mut stream = resp.into_inner(); + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + grpc::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + + let cancel = handler.cancel(); + + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + grpc::pb::health::health_check_response::ServingStatus::NotServing as i32 + ); + + cancel + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/backend/api/src/tests/grpc/mod.rs b/backend/api/src/tests/grpc/mod.rs new file mode 100644 index 00000000..b3053593 --- /dev/null +++ b/backend/api/src/tests/grpc/mod.rs @@ -0,0 +1,3 @@ +mod api; +mod health; +mod tls; diff --git a/backend/api/src/tests/grpc/tls.rs b/backend/api/src/tests/grpc/tls.rs new file mode 100644 index 00000000..7c631898 --- /dev/null +++ b/backend/api/src/tests/grpc/tls.rs @@ -0,0 +1,150 @@ +use common::grpc::{make_channel, TlsSettings}; +use common::prelude::FutureTimeout; +use std::path::PathBuf; +use std::time::Duration; +use tonic::transport::{Certificate, Identity}; + +use crate::config::{AppConfig, GrpcConfig, TlsConfig}; +use crate::grpc::{self, run}; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_tls_rsa() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/grpc/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.rsa.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.rsa.crt").to_str().unwrap().to_string(), + key: dir.join("server.rsa.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = + Certificate::from_pem(std::fs::read_to_string(dir.join("ca.rsa.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.rsa.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.rsa.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = grpc::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(grpc::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + grpc::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_tls_ec() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/grpc/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.ec.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.ec.crt").to_str().unwrap().to_string(), + key: dir.join("server.ec.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = Certificate::from_pem(std::fs::read_to_string(dir.join("ca.ec.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.ec.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.ec.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = grpc::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(grpc::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + grpc::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/backend/api/src/tests/mod.rs b/backend/api/src/tests/mod.rs index 20bb29d8..250afb11 100644 --- a/backend/api/src/tests/mod.rs +++ b/backend/api/src/tests/mod.rs @@ -1,4 +1,6 @@ mod api; mod config; +mod database; mod dataloader; mod global; +mod grpc; diff --git a/backend/migrations/20230217024406_Initial_Database_Structure.down.sql b/backend/migrations/20230217024406_Initial_Database_Structure.down.sql index 6e8d801c..ab824731 100644 --- a/backend/migrations/20230217024406_Initial_Database_Structure.down.sql +++ b/backend/migrations/20230217024406_Initial_Database_Structure.down.sql @@ -3,12 +3,9 @@ DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS sessions CASCADE; DROP TABLE IF EXISTS global_roles CASCADE; DROP TABLE IF EXISTS global_role_grants CASCADE; -DROP TABLE IF EXISTS global_bans CASCADE; -DROP TABLE IF EXISTS chat_rooms CASCADE; -DROP TABLE IF EXISTS channels CASCADE; DROP TABLE IF EXISTS channel_roles CASCADE; DROP TABLE IF EXISTS channel_role_grants CASCADE; DROP TABLE IF EXISTS streams CASCADE; -DROP TABLE IF EXISTS follows CASCADE; -DROP TABLE IF EXISTS channel_bans CASCADE; -DROP TABLE IF EXISTS chat_messages CASCADE; +DROP TABLE IF EXISTS stream_bitrate_updates CASCADE; +DROP TABLE IF EXISTS stream_variants CASCADE; +DROP TABLE IF EXISTS stream_events CASCADE; diff --git a/backend/migrations/20230217024406_Initial_Database_Structure.up.sql b/backend/migrations/20230217024406_Initial_Database_Structure.up.sql index 264df613..eac00563 100644 --- a/backend/migrations/20230217024406_Initial_Database_Structure.up.sql +++ b/backend/migrations/20230217024406_Initial_Database_Structure.up.sql @@ -1,183 +1,175 @@ -CREATE TABLE IF NOT EXISTS users ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), username varchar(32) NOT NULL, + display_name varchar(32) NOT NULL, password_hash varchar(255) NOT NULL, email varchar(255) NOT NULL, email_verified boolean NOT NULL DEFAULT FALSE, + + -- Stream state + stream_key varchar(255) NOT NULL, + stream_title varchar(255) NOT NULL DEFAULT '', + stream_description text NOT NULL DEFAULT '', + stream_transcoding_enabled boolean NOT NULL DEFAULT FALSE, + stream_recording_enabled boolean NOT NULL DEFAULT FALSE, + + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW(), last_login_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS sessions ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, +CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, -- foreign key to users(id) invalidated_at timestamptz DEFAULT NULL, + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW(), expires_at timestamptz NOT NULL DEFAULT NOW(), last_used_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS global_roles ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, +CREATE TABLE global_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name varchar(32) NOT NULL, description text NOT NULL, - rank int NOT NULL, + rank int NOT NULL CHECK (rank >= -1), -- allowed_permissions & denied_permissions = 0 -- We only need to check one of them allowed_permissions bigint NOT NULL DEFAULT 0 CHECK (allowed_permissions & denied_permissions = 0), denied_permissions bigint NOT NULL DEFAULT 0, + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS global_role_grants ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - global_role_id bigint NOT NULL REFERENCES global_roles (id) ON DELETE CASCADE, +CREATE TABLE global_role_grants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, -- foreign key to users(id) + global_role_id uuid NOT NULL, -- foreign key to global_roles(id) + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS global_bans ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - mode bigint NOT NULL, - reason text NOT NULL, - expires_at timestamptz DEFAULT NULL, - created_at timestamptz NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS chat_rooms ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - owner_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - name varchar(32) NOT NULL, - description text NOT NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - deleted_at timestamptz DEFAULT NULL -); - -CREATE TABLE IF NOT EXISTS channels ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - owner_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - name varchar(32) NOT NULL, - description text NOT NULL, - stream_key char(25) NOT NULL, - chat_room_id bigint REFERENCES chat_rooms (id) ON DELETE CASCADE DEFAULT NULL , - last_live_at timestamptz DEFAULT NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - deleted_at timestamptz DEFAULT NULL -); - -CREATE TABLE IF NOT EXISTS channel_roles ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - owner_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - channel_id bigint REFERENCES channels (id) ON DELETE CASCADE DEFAULT NULL, +CREATE TABLE channel_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL, -- foreign key to users(id) name varchar(32) NOT NULL, description text NOT NULL, - rank int NOT NULL, + rank int NOT NULL CHECK (rank >= -1), -- allowed_permissions & denied_permissions = 0 -- We only need to check one of them allowed_permissions bigint NOT NULL DEFAULT 0 CHECK (allowed_permissions & denied_permissions = 0), denied_permissions bigint NOT NULL DEFAULT 0, + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS channel_role_grants ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - channel_role_id bigint NOT NULL REFERENCES channel_roles (id) ON DELETE CASCADE, +CREATE TABLE channel_role_grants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, -- foreign key to users(id) + channel_role_id uuid NOT NULL, -- foreign key to channel_roles(id) + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS streams ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - channel_id bigint NOT NULL REFERENCES channels (id) ON DELETE CASCADE, +CREATE TABLE streams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL, -- foreign key to users(id) title varchar(255) NOT NULL, description text NOT NULL, + recorded boolean NOT NULL DEFAULT FALSE, + transcoded boolean NOT NULL DEFAULT FALSE, + deleted boolean NOT NULL DEFAULT FALSE, + state int NOT NULL DEFAULT 0, -- 0 = not ready, 1 = ready, 2 = stopped, 3 = stopped resumable, 4 = failed, 5 = was ready + ingest_address varchar(255) NOT NULL, + connection_id uuid NOT NULL, + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW(), - started_at timestamptz DEFAULT NULL, - ended_at timestamptz DEFAULT NULL + updated_at timestamptz DEFAULT NULL, -- NULL = not started (last bitrate is report) + ended_at timestamptz NOT NULL DEFAULT NOW() + interval '5 minutes' ); -CREATE TABLE IF NOT EXISTS follows ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - follower_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - followed_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - channel_id bigint REFERENCES channels (id) ON DELETE CASCADE DEFAULT NULL, +CREATE TABLE stream_bitrate_updates ( + stream_id uuid NOT NULL, -- foreign key to streams(id) + video_bitrate bigint NOT NULL, + audio_bitrate bigint NOT NULL, + metadata_bitrate bigint NOT NULL, + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS channel_bans ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - owner_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - target_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - channel_id bigint REFERENCES channels (id) ON DELETE CASCADE DEFAULT NULL, - mode bigint NOT NULL, - reason text NOT NULL, - expires_at timestamptz DEFAULT NULL, +CREATE TABLE stream_variants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id uuid NOT NULL, -- foreign key to streams(id) + name varchar(255) NOT NULL, + video_framerate int, -- null = audio only + video_width int, -- null = audio only + video_height int, -- null = audio only + video_bitrate int, -- null = audio only + video_codec varchar(255), -- null = audio only + audio_sample_rate int, -- null = video only + audio_channels int, -- null = video only + audio_bitrate int, -- null = video only + audio_codec varchar(255), -- null = video only + metadata jsonb NOT NULL DEFAULT '{}', + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS chat_messages ( - id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, - chat_room_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, - author_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, +CREATE TABLE stream_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id uuid NOT NULL, -- foreign key to streams(id) + title varchar(255) NOT NULL, message text NOT NULL, + level int NOT NULL, -- 0 = info, 1 = warning, 2 = error + -- Timestamps created_at timestamptz NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS channel_bans_user_id_idx ON channel_bans (owner_id); - -CREATE INDEX IF NOT EXISTS channel_bans_target_id_idx ON channel_bans (target_id); - -CREATE INDEX IF NOT EXISTS channel_bans_channel_id_idx ON channel_bans (channel_id); - -CREATE INDEX IF NOT EXISTS channel_role_grants_channel_role_id_idx ON channel_role_grants (channel_role_id); - -CREATE INDEX IF NOT EXISTS channel_role_grants_user_id_idx ON channel_role_grants (user_id); - -CREATE INDEX IF NOT EXISTS channel_roles_owner_id_idx ON channel_roles (owner_id); - -CREATE INDEX IF NOT EXISTS channel_roles_channel_id_idx ON channel_roles (channel_id); - -CREATE UNIQUE INDEX IF NOT EXISTS channel_roles_name_idx ON channel_roles (owner_id, channel_id, name); - -CREATE UNIQUE INDEX IF NOT EXISTS channel_roles_rank_idx ON channel_roles (owner_id, channel_id, rank); - -CREATE INDEX IF NOT EXISTS channels_owner_id_idx ON channels (owner_id); - -CREATE UNIQUE INDEX IF NOT EXISTS channels_name_idx ON channels (owner_id, name) WHERE deleted_at IS NULL; +-- Indexes -CREATE INDEX IF NOT EXISTS chat_messages_chat_room_id_idx ON chat_messages (chat_room_id); +CREATE INDEX users_username_idx ON users (username); -CREATE INDEX IF NOT EXISTS chat_messages_author_id_idx ON chat_messages (author_id); +CREATE INDEX global_roles_user_id_idx ON global_role_grants (user_id); +CREATE INDEX global_roles_global_role_id_idx ON global_role_grants (global_role_id); +CREATE INDEX global_roles_rank_idx ON global_roles (rank); -CREATE INDEX IF NOT EXISTS chat_rooms_owner_id_idx ON chat_rooms (owner_id); +CREATE INDEX channel_roles_user_id_idx ON channel_role_grants (user_id); +CREATE INDEX channel_roles_channel_role_id_idx ON channel_role_grants (channel_role_id); +CREATE INDEX channel_roles_rank_idx ON channel_roles (rank); -CREATE UNIQUE INDEX IF NOT EXISTS chat_rooms_name_idx ON chat_rooms (owner_id, name) WHERE deleted_at IS NULL; +CREATE INDEX streams_channel_id_idx ON streams (channel_id); -CREATE INDEX IF NOT EXISTS follows_follower_id_idx ON follows (follower_id); +CREATE INDEX stream_bitrate_updates_stream_id_idx ON stream_bitrate_updates (stream_id); +CREATE INDEX stream_bitrate_updates_created_at_idx ON stream_bitrate_updates (created_at); -CREATE INDEX IF NOT EXISTS follows_followed_id_idx ON follows (followed_id); +CREATE INDEX stream_variants_stream_id_idx ON stream_variants (stream_id); -CREATE UNIQUE INDEX IF NOT EXISTS follows_unique_idx ON follows (follower_id, followed_id, channel_id); +CREATE INDEX stream_events_stream_id_idx ON stream_events (stream_id); +-- CONSTRAINTS -CREATE INDEX IF NOT EXISTS follows_channel_id_idx ON follows (channel_id); +ALTER TABLE IF EXISTS users ADD CONSTRAINT users_username_unique UNIQUE (username); -CREATE INDEX IF NOT EXISTS global_bans_user_id_idx ON global_bans (user_id); +ALTER TABLE IF EXISTS global_roles ADD CONSTRAINT global_roles_name_unique UNIQUE (name); +ALTER TABLE IF EXISTS global_roles ADD CONSTRAINT global_roles_rank_unique UNIQUE (rank); -CREATE INDEX IF NOT EXISTS global_role_grants_global_role_id_idx ON global_role_grants (global_role_id); +ALTER TABLE IF EXISTS channel_roles ADD CONSTRAINT channel_roles_name_unique UNIQUE (channel_id, name); +ALTER TABLE IF EXISTS channel_roles ADD CONSTRAINT channel_roles_rank_unique UNIQUE (channel_id, rank); -CREATE INDEX IF NOT EXISTS global_role_grants_user_id_idx ON global_role_grants (user_id); +ALTER TABLE IF EXISTS stream_variants ADD CONSTRAINT stream_variants_name_unique UNIQUE (stream_id, name); +-- Foreign keys -CREATE UNIQUE INDEX IF NOT EXISTS global_roles_name_idx ON global_roles (name); +ALTER TABLE sessions ADD CONSTRAINT sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -CREATE UNIQUE INDEX IF NOT EXISTS global_roles_rank_idx ON global_roles (rank); +ALTER TABLE global_role_grants ADD CONSTRAINT global_role_grants_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE global_role_grants ADD CONSTRAINT global_role_grants_global_role_id_fkey FOREIGN KEY (global_role_id) REFERENCES global_roles(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id); +ALTER TABLE channel_role_grants ADD CONSTRAINT channel_role_grants_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE channel_role_grants ADD CONSTRAINT channel_role_grants_channel_role_id_fkey FOREIGN KEY (channel_role_id) REFERENCES channel_roles(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS streams_channel_id_idx ON streams (channel_id); +ALTER TABLE streams ADD CONSTRAINT streams_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES users(id) ON DELETE CASCADE; -CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users (username); +ALTER TABLE stream_bitrate_updates ADD CONSTRAINT stream_bitrate_updates_stream_id_fkey FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS users_email_idx ON users (email); +ALTER TABLE stream_variants ADD CONSTRAINT stream_variants_stream_id_fkey FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS users_email_verified_idx ON users (email_verified); +ALTER TABLE stream_events ADD CONSTRAINT stream_events_stream_id_fkey FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE; diff --git a/common/Cargo.toml b/common/Cargo.toml index 56dcb8e8..252cbc1b 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -5,21 +5,42 @@ edition = "2021" authors = ["Scuffle "] description = "Scuffle Common Library" +[features] +logging = ["dep:log", "dep:tracing", "dep:tracing-log", "dep:tracing-subscriber", "dep:arc-swap", "dep:anyhow"] +rmq = ["dep:lapin", "dep:arc-swap", "dep:anyhow", "dep:futures", "dep:tracing", "dep:tokio", "dep:async-stream", "prelude"] +grpc = ["dep:tonic", "dep:anyhow", "dep:async-trait", "dep:futures", "dep:http", "dep:tower", "dep:trust-dns-resolver", "dep:tracing"] +context = ["dep:tokio", "dep:tokio-util"] +prelude = ["dep:tokio"] +signal = [] +macros = [] +config = ["dep:config", "dep:serde"] + +default = ["logging", "rmq", "grpc", "context", "prelude", "signal", "macros", "config"] + [dependencies] -config = "0.13.3" -tracing = "0.1.37" -anyhow = "1.0.69" -serde = { version = "1.0.152", features = ["derive"] } -tracing-subscriber = { version = "0.3.16", features = ["fmt", "env-filter", "json"] } -chrono = { version = "0.4.23", default-features = false, features = ["clock"] } -bitmask-enum = "2.1.0" -async-trait = "0.1.64" -sqlx = { version = "0.6.2", features = ["postgres", "offline", "runtime-tokio-rustls"] } -tokio = { version = "1.25.0", features = ["full"] } -argon2 = "0.4.1" -tracing-log = { version = "0.1.2", features = ["env_logger"] } -log = "0.4.17" -email_address = "0.2.4" +log = { version = "0", optional = true } +http = { version = "0", optional = true } +tower = { version = "0", optional = true } +config = { version = "0", optional = true } +anyhow = { version = "1", optional = true } +futures = { version = "0", optional = true } +tracing = { version = "0", optional = true } +arc-swap = { version = "1", optional = true } +tokio-util = { version = "0", optional = true } +async-trait = { version = "0", optional = true } +async-stream = { version = "0", optional = true } +tonic = { version = "0", features = ["tls"], optional = true } +tokio = { version = "1", features = ["sync", "rt"], optional = true } +serde = { version = "1", features = ["derive"], optional = true } +lapin = { version = "2.0.3", features = ["native-tls"], optional = true } +tracing-log = { version = "0", features = ["env_logger"], optional = true } +trust-dns-resolver = { version = "0", features = ["tokio-runtime"], optional = true } +tracing-subscriber = { version = "0", features = ["fmt", "env-filter", "json"], optional = true } [dev-dependencies] -tempfile = "3.3.0" +prost = "0" +tempfile = "3" +portpicker = "0" + +[build-dependencies] +tonic-build = "0" diff --git a/common/build.rs b/common/build.rs new file mode 100644 index 00000000..a091e948 --- /dev/null +++ b/common/build.rs @@ -0,0 +1,3 @@ +fn main() { + tonic_build::compile_protos("proto/test.proto").unwrap(); +} diff --git a/common/proto/test.proto b/common/proto/test.proto new file mode 100644 index 00000000..cc9cdc83 --- /dev/null +++ b/common/proto/test.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package test; + +service Test { + rpc test(TestRequest) returns (TestResponse) {} +} + +message TestRequest { + string message = 1; +} + +message TestResponse { + string server = 1; + string message = 2; +} diff --git a/common/src/context.rs b/common/src/context.rs index c892b6aa..59aaddf8 100644 --- a/common/src/context.rs +++ b/common/src/context.rs @@ -5,17 +5,15 @@ use std::{ sync::{Arc, Weak}, }; -use tokio::{ - sync::{broadcast, oneshot}, - time::Instant, -}; +use tokio::{sync::oneshot, time::Instant}; +use tokio_util::sync::{CancellationToken, DropGuard}; struct RawContext { _sender: oneshot::Sender<()>, _weak: Weak<()>, deadline: Option, parent: Option, - cancel_receiver: broadcast::Receiver<()>, + cancel_receiver: CancellationToken, } #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -39,20 +37,21 @@ impl RawContext { #[must_use] fn new() -> (Self, Handler) { let (sender, recv) = oneshot::channel(); - let (cancel_sender, cancel_receiver) = broadcast::channel(1); let strong = Arc::new(()); + let token = CancellationToken::new(); + let child = token.child_token(); ( Self { - _sender: sender, deadline: None, parent: None, - cancel_receiver, + cancel_receiver: child, + _sender: sender, _weak: Arc::downgrade(&strong), }, Handler { recv, - _cancel_sender: cancel_sender, + _token: token.drop_guard(), _strong: strong, }, ) @@ -73,31 +72,30 @@ impl RawContext { (ctx, handler) } - fn done(&self) -> Pin + '_ + Send>> { - let mut recv = self.cancel_receiver.resubscribe(); + fn done(&self) -> Pin + '_ + Send + Sync>> { Box::pin(async move { match (&self.parent, self.deadline) { (Some(parent), Some(deadline)) => { tokio::select! { _ = parent.done() => CancelReason::Parent, _ = tokio::time::sleep_until(deadline) => CancelReason::Deadline, - _ = recv.recv() => CancelReason::Cancel, + _ = self.cancel_receiver.cancelled() => CancelReason::Cancel, } } (Some(parent), None) => { tokio::select! { _ = parent.done() => CancelReason::Parent, - _ = recv.recv() => CancelReason::Cancel, + _ = self.cancel_receiver.cancelled() => CancelReason::Cancel, } } (None, Some(deadline)) => { tokio::select! { _ = tokio::time::sleep_until(deadline) => CancelReason::Deadline, - _ = recv.recv() => CancelReason::Cancel, + _ = self.cancel_receiver.cancelled() => CancelReason::Cancel, } } (None, None) => { - let _ = recv.recv().await; + self.cancel_receiver.cancelled().await; CancelReason::Cancel } } @@ -111,8 +109,9 @@ impl RawContext { pub struct Handler { _strong: Arc<()>, + _token: DropGuard, + recv: oneshot::Receiver<()>, - _cancel_sender: broadcast::Sender<()>, } impl Handler { @@ -120,7 +119,7 @@ impl Handler { let _ = (&mut self.recv).await; } - pub fn cancel(self) -> Pin + Send>> { + pub fn cancel(self) -> Pin + Send + Sync>> { let recv = self.recv; Box::pin(async move { let _ = recv.await; @@ -158,7 +157,7 @@ impl Context { (ctx.into(), handler) } - pub fn done(&self) -> Pin + '_ + Send>> { + pub fn done(&self) -> Pin + '_ + Send + Sync>> { self.0.done() } diff --git a/common/src/grpc.rs b/common/src/grpc.rs new file mode 100644 index 00000000..4cc6e5a9 --- /dev/null +++ b/common/src/grpc.rs @@ -0,0 +1,386 @@ +use std::{collections::HashSet, fmt, net::SocketAddr, time::Duration}; + +use anyhow::Result; +use async_trait::async_trait; +use futures::future; +use http::Uri; +use tokio::sync::mpsc::Sender; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; +use tower::discover::Change; +use trust_dns_resolver::{ + error::ResolveError, + lookup::Lookup, + proto::rr::{RData, RecordType}, + TokioAsyncResolver, +}; + +#[derive(Clone, Debug)] +/// Options for creating a gRPC channel. +pub struct ChannelOpts { + /// A list of addresses to connect to. If this is empty, will return an error. + /// Can be a hostname or an IP address. + pub addresses: Vec, + /// If true, will try to resolve CNAME records for the hostname. + /// Useful for headless services. If false, will only resolve A/AAAA records. + pub try_cname: bool, + /// If true, will try to resolve IPv6 addresses. + pub enable_ipv6: bool, + /// If true, will try to resolve IPv4 addresses. + pub enable_ipv4: bool, + /// How often to re-resolve the hostnames. If this is 0, will only resolve once. + pub interval: Duration, + /// TLS settings. Is None if TLS is disabled. If this is Some, will use TLS. + pub tls: Option, +} + +#[derive(Clone, Debug)] +pub struct TlsSettings { + /// The domain on the certificate. + pub domain: String, + /// The client certificate. + pub identity: Identity, + /// The CA certificate to verify the server. + pub ca_cert: Certificate, +} + +/// Internal struct for controlling the channel. +/// Automatically resolves hostnames and handles DNS changes. +struct ChannelController { + last_addresses: HashSet, + resolver: R, + try_cname: bool, + enable_ipv6: bool, + enable_ipv4: bool, + sender: Sender>, + interval: Duration, + hostnames: HashSet<(String, u16)>, + static_ips: HashSet, + tls: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +/// A wrapper around SocketAddr and CNAME records. +/// Hashable to be used in a HashSet. +enum EndpointType { + Ip(SocketAddr), + CName(String, u16), +} + +impl fmt::Display for EndpointType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ip(addr) => write!(f, "{}", addr), + Self::CName(name, port) => write!(f, "{}:{}", name, port), + } + } +} + +#[async_trait] +pub trait DnsResolver: Send + Sync + 'static { + async fn lookup(&self, hostname: &str, record_type: RecordType) + -> Result; +} + +#[async_trait] +impl DnsResolver for TokioAsyncResolver { + #[inline(always)] + async fn lookup( + &self, + hostname: &str, + record_type: RecordType, + ) -> Result { + self.lookup(hostname, record_type).await + } +} + +struct ChannelControllerOpts { + sender: Sender>, + interval: Duration, + hostnames: HashSet<(String, u16)>, + static_ips: HashSet, + tls: Option, + try_cname: bool, + enable_ipv6: bool, + enable_ipv4: bool, +} + +impl ChannelController { + fn new(resolver: R, opts: ChannelControllerOpts) -> Result { + Ok(Self { + last_addresses: HashSet::new(), + resolver, + sender: opts.sender, + interval: opts.interval, + hostnames: opts.hostnames, + static_ips: opts.static_ips, + try_cname: opts.try_cname, + enable_ipv6: opts.enable_ipv6, + enable_ipv4: opts.enable_ipv4, + tls: opts.tls, + }) + } + + /// Starts the controller + pub async fn start(mut self) { + // We start by running the first lookup. + while self.run().await { + // if the interval is 0, we only run once. + if self.interval == Duration::from_secs(0) { + break; + } + + tokio::time::sleep(self.interval).await; + } + } + + /// Runs a single lookup. + async fn run(&mut self) -> bool { + let mut addresses = self + .static_ips + .clone() + .into_iter() + .map(EndpointType::Ip) + .collect::>(); + + let futures = self.hostnames.iter().map(|(hostname, port)| { + let resolver = &self.resolver; + let try_cname = self.try_cname; + let port = *port; + + let enable_ipv4 = self.enable_ipv4; + let enable_ipv6 = self.enable_ipv6; + + // This needs to be a move, because we need the port and hostname. + async move { + if try_cname { + let cname = resolver.lookup(hostname, RecordType::CNAME).await; + if let Ok(cname) = cname { + return Ok((cname, port)); + } + } + + if enable_ipv4 { + let lookup = resolver.lookup(hostname, RecordType::A).await; + if let Ok(lookup) = lookup { + return Ok((lookup, port)); + } + } + + if enable_ipv6 { + let lookup = resolver.lookup(hostname, RecordType::AAAA).await; + if let Ok(lookup) = lookup { + return Ok((lookup, port)); + } + } + + Err(anyhow::anyhow!("Failed to resolve hostname: {}", hostname)) + } + }); + + future::join_all(futures) + .await + .into_iter() + .for_each(|result| match result { + // If the lookup was successful, we add all the addresses to the total list. + Ok((lookup, port)) => { + lookup + .into_iter() + // We convert the IpAddr to a SocketAddr, so we can add it to the HashSet. + // Since we are using a filter_map here, we can also filter out any records that we don't care about. + .filter_map(move |record| { + match record { + // If we get an A record back, we convert it to an SocketAddr with the port and then into a EndpointType::Ip. + RData::A(a) => { + Some(EndpointType::Ip(SocketAddr::new(a.into(), port))) + } + // If we get an AAAA record back, we convert it to an SocketAddr with the port and then into a EndpointType::Ip. + RData::AAAA(aaaa) => { + Some(EndpointType::Ip(SocketAddr::new(aaaa.into(), port))) + } + // If we get a CNAME record back, we convert it to an EndpointType::CName with the port. + RData::CNAME(cname) => { + Some(EndpointType::CName(cname.to_string(), port)) + } + // This should never happen, but we have to handle it. We just ignore it. + _ => None, + } + }) + // Now for all the records we got back, we add them to the HashSet. + .for_each(|endpoint| { + // This is a HashSet, so we don't have to worry about duplicates. + addresses.insert(endpoint); + }); + } + Err(e) => { + // If the lookup failed, we log the error. + tracing::debug!("failed to lookup address: {}", e); + } + }); + + // Now we check if there are any new addresses. + let added = addresses + .difference(&self.last_addresses) + // If we have a new address, we need to construct a Change to add it to the channel. + .filter_map(|addr| { + // First we need to make a Endpoint from the EndpointType. + let endpoint = if self.tls.is_some() { + Endpoint::from_shared(format!("https://{}", addr)) + } else { + Endpoint::from_shared(format!("http://{}", addr)) + }; + + // If we failed to make a Endpoint, we log the error and return None. + let endpoint = match endpoint { + Ok(endpoint) => endpoint, + Err(e) => { + tracing::warn!("invalid address: {}, {}", addr, e); + return None; + } + }; + + // If TLS is enabled, we need to add the TLS config to the Endpoint. + let endpoint = if self.tls.is_some() { + let tls = self.tls.as_ref().unwrap(); + let tls = ClientTlsConfig::new() + .domain_name(tls.domain.clone()) + .ca_certificate(tls.ca_cert.clone()) + .identity(tls.identity.clone()); + + match endpoint.tls_config(tls) { + Ok(endpoint) => endpoint, + Err(e) => { + tracing::warn!("invalid tls config: {}: {}", addr, e); + return None; + } + } + } else { + endpoint + }; + + // We now construct the Change and return it. + Some(Change::Insert(addr.clone(), endpoint)) + }); + + // Now we check if there are any addresses that have been removed. + let removed = self + .last_addresses + .difference(&addresses) + // We construct a Change to remove the address from the channel. + .map(|addr| Change::Remove(addr.clone())); + + // We combine the 2 streams into one. + let changes = added.chain(removed); + + // Now we send all the changes to the channel. + for change in changes { + // If this fails, it means the receiver has been dropped, so we can stop the loop. + if self.sender.send(change).await.is_err() { + tracing::debug!("channel controller stopped"); + return false; + } + } + + // We then update the last_addresses HashSet with the new addresses. + self.last_addresses = addresses; + + // We return true, so the loop will continue. + true + } +} + +/// Make a new gRPC transport channel which is backed by a DNS resolver. +/// This will create a new channel which will automatically update the endpoints +/// when the DNS records change. Allowing for a more dynamic way of connecting +/// to services. +#[inline(always)] +pub fn make_channel( + addresses: Vec, + interval: Duration, + tls: Option, +) -> Result { + make_channel_with_opts(ChannelOpts { + addresses, + tls, + interval, + enable_ipv4: true, + enable_ipv6: true, + try_cname: true, + }) +} + +/// Make a new gRPC transport channel which is backed by a DNS resolver. +/// This will create a new channel which will automatically update the endpoints +/// when the DNS records change. Allowing for a more dynamic way of connecting +/// to services. This funtion allows you to provide your own options. +#[inline(always)] +pub fn make_channel_with_opts(opts: ChannelOpts) -> Result { + make_channel_with_resolver(TokioAsyncResolver::tokio_from_system_conf()?, opts) +} + +/// Make a new gRPC transport channel which is backed by a DNS resolver. +/// This will create a new channel which will automatically update the endpoints +/// when the DNS records change. Allowing for a more dynamic way of connecting +/// to services. This function allows you to provide your own DNS resolver. +/// This is useful if you want to use a different DNS resolver, or if you want +/// to unit test this function. +pub fn make_channel_with_resolver( + resolver: R, + opts: ChannelOpts, +) -> Result { + // We first check if any addresses were provided. + if opts.addresses.is_empty() { + return Err(anyhow::anyhow!("no addresses provided")); + } + + // 128 is an arbitrary number, but it should be enough for most use cases. + let (channel, sender) = Channel::balance_channel(128); + + let mut static_ips = HashSet::new(); + let mut hostnames = HashSet::new(); + + // We iterate over the provided addresses and parse them into a Uri. + // So we can check if the address is a hostname or an IP address. + for address in opts.addresses { + let uri = address.parse::()?; + + // Get the port from the Uri, or use the default port. + let port = uri + .port_u16() + .unwrap_or(if opts.tls.is_some() { 443 } else { 80 }); + + // Get the host from the Uri, or return an error if it doesn't exist. + let Some(address) = uri.host() else { + return Err(anyhow::anyhow!("invalid address: {}", address)); + }; + + // If the address is an IP address, we add it to the ip_addresses HashSet. + if let Ok(addr) = address.parse::() { + static_ips.insert(SocketAddr::new(addr, port)); + } else { + hostnames.insert((address.to_string(), port)); + } + } + + // We now create a new ChannelController + // The channel controller will handle the DNS lookups and updating the channel. + let controller = ChannelController::new( + resolver, + ChannelControllerOpts { + sender, + interval: opts.interval, + hostnames, + static_ips, + tls: opts.tls, + try_cname: opts.try_cname, + enable_ipv6: opts.enable_ipv6, + enable_ipv4: opts.enable_ipv4, + }, + )?; + + // We spawn the controller on a new task. + tokio::spawn(controller.start()); + + // We return the channel. + // The channel will be updated by the controller. + Ok(channel) +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 605eefc7..4b32a44a 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,10 +1,23 @@ #![forbid(unsafe_code)] +#[cfg(feature = "config")] pub mod config; +#[cfg(feature = "context")] pub mod context; +#[cfg(feature = "grpc")] +pub mod grpc; +#[cfg(feature = "logging")] pub mod logging; +#[cfg(feature = "prelude")] +pub mod prelude; +#[cfg(feature = "rmq")] +pub mod rmq; +#[cfg(feature = "signal")] pub mod signal; -pub mod types; + +#[cfg(feature = "macros")] +#[macro_use] +pub mod macros; #[cfg(test)] mod tests; diff --git a/common/src/logging.rs b/common/src/logging.rs index 2e079517..6a9271cc 100644 --- a/common/src/logging.rs +++ b/common/src/logging.rs @@ -1,17 +1,58 @@ +use std::{str::FromStr, sync::RwLock}; + use anyhow::Result; -use tracing_log::LogTracer; +use tracing_subscriber::{ + prelude::*, + reload::{self, Handle}, + EnvFilter, Layer, Registry, +}; + +type HandleType = Handle; + +static ONCE: std::sync::Once = std::sync::Once::new(); +static RELOAD_HANDLE: RwLock> = RwLock::new(None); -pub fn init(level: &str) -> Result<()> { - tracing::subscriber::set_global_default( - tracing_subscriber::FmtSubscriber::builder() - .with_env_filter(level) +pub fn init(level: &str, json: bool) -> Result<()> { + let mut result: Result<(), anyhow::Error> = Ok(()); + ONCE.call_once(|| { + let (env_filter, handle) = + reload::Layer::new(EnvFilter::from_str(level).expect("failed to parse log level")); + + let filter = tracing_subscriber::fmt::layer() .with_line_number(true) - .with_file(true) - .json() - .finish(), - )?; + .with_file(true); + + if json { + let filter = filter.json().with_filter(env_filter); + + let registry = tracing_subscriber::registry().with(filter); + + result = tracing::subscriber::set_global_default(registry).map_err(|e| e.into()); + if result.is_err() { + return; + } + } else { + let filter = filter.with_filter(env_filter); + + let registry = tracing_subscriber::registry().with(filter); + + result = tracing::subscriber::set_global_default(registry).map_err(|e| e.into()); + if result.is_err() { + return; + } + } + + *RELOAD_HANDLE.write().expect("failed to write to handler") = Some(handle); + }); + + result?; - LogTracer::init()?; + RELOAD_HANDLE + .read() + .expect("failed to read mutex") + .as_ref() + .expect("failed to get reload handle") + .reload(level)?; Ok(()) } diff --git a/common/src/macros.rs b/common/src/macros.rs new file mode 100644 index 00000000..ab957360 --- /dev/null +++ b/common/src/macros.rs @@ -0,0 +1,4 @@ +#[macro_export] +macro_rules! vec_of_strings { + ($($x:expr),* $(,)?) => (vec![$($x.into()),*] as Vec); +} diff --git a/common/src/prelude/futures.rs b/common/src/prelude/futures.rs new file mode 100644 index 00000000..52168960 --- /dev/null +++ b/common/src/prelude/futures.rs @@ -0,0 +1,16 @@ +use std::time::Duration; + +use futures::Future; +use tokio::time::Timeout; + +pub trait FutureTimeout: Future { + #[inline(always)] + fn timeout(self, duration: Duration) -> Timeout + where + Self: Sized, + { + tokio::time::timeout(duration, self) + } +} + +impl FutureTimeout for F {} diff --git a/common/src/prelude/mod.rs b/common/src/prelude/mod.rs new file mode 100644 index 00000000..693ccf24 --- /dev/null +++ b/common/src/prelude/mod.rs @@ -0,0 +1,3 @@ +mod futures; + +pub use self::futures::*; diff --git a/common/src/rmq.rs b/common/src/rmq.rs new file mode 100644 index 00000000..84cf9c8a --- /dev/null +++ b/common/src/rmq.rs @@ -0,0 +1,197 @@ +use std::{ + sync::{atomic::AtomicUsize, Arc}, + time::Duration, +}; + +use anyhow::{anyhow, Result}; +use arc_swap::ArcSwap; +use async_stream::stream; +use futures::{Stream, StreamExt}; +use lapin::{ + options::BasicConsumeOptions, topology::TopologyDefinition, types::FieldTable, Channel, + Connection, ConnectionProperties, +}; +use tokio::sync::{broadcast, mpsc, Mutex}; +use tracing::{info_span, Instrument}; + +use crate::prelude::FutureTimeout; + +pub struct ConnectionPool { + uri: String, + timeout: Duration, + properties: ConnectionProperties, + error_queue: mpsc::Sender, + error_queue_rx: Mutex>, + new_connection_waker: broadcast::Sender<()>, + connections: Vec>, + aquire_idx: AtomicUsize, +} + +impl ConnectionPool { + pub async fn connect( + uri: String, + properties: ConnectionProperties, + timeout: Duration, + pool_size: usize, + ) -> Result { + let connections = Vec::with_capacity(pool_size); + let (tx, rx) = mpsc::channel(pool_size); + + let mut pool = Self { + uri, + properties, + timeout, + connections, + error_queue: tx, + error_queue_rx: Mutex::new(rx), + new_connection_waker: broadcast::channel(1).0, + aquire_idx: AtomicUsize::new(0), + }; + + for i in 0..pool_size { + let conn = pool.new_connection(i, None).await?; + pool.connections.push(ArcSwap::from(Arc::new(conn))); + } + + Ok(pool) + } + + pub async fn handle_reconnects(&self) -> Result<()> { + loop { + let idx = self + .error_queue_rx + .lock() + .await + .recv() + .await + .expect("error queue closed"); + let conn = async { + loop { + let conn = match self + .new_connection(idx, Some(self.connections[idx].load().topology())) + .await + { + Ok(conn) => conn, + Err(err) => { + tracing::error!("failed to reconnect: {}", err); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }; + + tracing::info!("reconnected to rabbitmq"); + break conn; + } + } + .instrument(info_span!("reconnect rmq", idx)) + .timeout(self.timeout) + .await?; + + self.connections[idx].store(Arc::new(conn)); + self.new_connection_waker.send(()).ok(); + } + } + + pub async fn new_connection( + &self, + idx: usize, + topology: Option, + ) -> Result { + let conn = Connection::connect(&self.uri, self.properties.clone()) + .timeout(self.timeout) + .await??; + + if let Some(topology) = topology { + conn.restore(topology).await?; + } + + let sender = self.error_queue.clone(); + conn.on_error(move |e| { + tracing::error!("rabbitmq error: {:?}", e); + + if let Err(err) = sender.try_send(idx) { + tracing::error!("failed to reload connection: {}", err); + } + }); + + Ok(conn) + } + + pub fn basic_consume( + &self, + queue_name: impl ToString, + connection_name: impl ToString, + options: BasicConsumeOptions, + table: FieldTable, + ) -> impl Stream> + '_ { + let queue_name = queue_name.to_string(); + let connection_name = connection_name.to_string(); + + stream! { + 'connection_loop: loop { + let channel = self.aquire().await?; + let mut consumer = channel.basic_consume(&queue_name, &connection_name, options, table.clone()).await?; + loop { + let m = consumer.next().await; + match m { + Some(Ok(m)) => { + yield Ok(m); + }, + Some(Err(e)) => { + match e { + lapin::Error::IOError(e) => { + if e.kind() == std::io::ErrorKind::ConnectionReset { + continue 'connection_loop; + } + }, + _ => { + yield Err(anyhow!("failed to get message: {}", e)); + } + } + }, + None => { + continue 'connection_loop; + }, + } + } + } + } + } + + pub async fn aquire(&self) -> Result { + let mut done = false; + loop { + let mut conn = None; + let start_idx = self + .aquire_idx + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + % self.connections.len(); + for c in self.connections[start_idx..] + .iter() + .chain(self.connections[..start_idx].iter()) + { + let loaded = c.load(); + if loaded.status().connected() { + conn = Some(loaded.clone()); + break; + } + } + + if let Some(conn) = conn { + let channel = conn.create_channel().await?; + return Ok(channel); + } + + if done { + return Err(anyhow!("no connections available")); + } + + done = true; + self.new_connection_waker + .subscribe() + .recv() + .timeout(self.timeout) + .await??; + } + } +} diff --git a/common/src/tests/config.rs b/common/src/tests/config.rs index b3eece96..bf9ffdf5 100644 --- a/common/src/tests/config.rs +++ b/common/src/tests/config.rs @@ -2,6 +2,14 @@ use serde::Deserialize; use crate::config::parse; +fn clear_env() { + for (key, _) in std::env::vars() { + if key.starts_with("SCUF_") { + std::env::remove_var(key); + } + } +} + #[derive(Deserialize, Debug, Default)] struct Config { foo: String, @@ -10,6 +18,8 @@ struct Config { #[test] fn test_parse() { + clear_env(); + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let config_file = tmp_dir.path().join("config.toml"); @@ -30,6 +40,8 @@ bar = "bar" #[test] fn test_parse_env() { + clear_env(); + std::env::set_var("SCUF_FOO", "foo"); std::env::set_var("SCUF_BAR", "bar"); diff --git a/common/src/tests/context.rs b/common/src/tests/context.rs index 5466d7de..4bb508de 100644 --- a/common/src/tests/context.rs +++ b/common/src/tests/context.rs @@ -2,7 +2,10 @@ use std::time::Duration; use tokio::time::Instant; -use crate::context::{CancelReason, Context}; +use crate::{ + context::{CancelReason, Context}, + prelude::FutureTimeout, +}; #[tokio::test] async fn test_context_cancel() { @@ -13,10 +16,13 @@ async fn test_context_cancel() { assert_eq!(reason, CancelReason::Cancel); }); - tokio::time::timeout(Duration::from_millis(300), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); @@ -31,11 +37,14 @@ async fn test_context_deadline() { assert_eq!(reason, CancelReason::Deadline); }); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); - tokio::time::timeout(Duration::from_millis(300), handler.done()) + handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); } @@ -54,7 +63,8 @@ async fn test_context_is_done() { tokio::time::sleep(Duration::from_millis(100)).await; drop(handler); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); @@ -69,11 +79,14 @@ async fn test_context_timeout() { assert_eq!(reason, CancelReason::Deadline); }); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); - tokio::time::timeout(Duration::from_millis(300), handler.done()) + handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); } @@ -88,14 +101,19 @@ async fn test_context_parent() { assert_eq!(reason, CancelReason::Parent); }); - tokio::time::timeout(Duration::from_millis(300), parent_handler.cancel()) + parent_handler + .cancel() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); - tokio::time::timeout(Duration::from_millis(300), handler.done()) + handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); } @@ -111,13 +129,18 @@ async fn test_context_parent_deadline() { assert_eq!(reason, CancelReason::Deadline); }); - tokio::time::timeout(Duration::from_millis(300), parent_handler.done()) + parent_handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handler.done()) + handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); @@ -134,13 +157,18 @@ async fn test_context_parent_deadline_cancel() { assert_eq!(reason, CancelReason::Cancel); }); - tokio::time::timeout(Duration::from_millis(300), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), parent_handler.done()) + parent_handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); @@ -157,13 +185,18 @@ async fn test_context_parent_deadline_parent_cancel() { assert_eq!(reason, CancelReason::Parent); }); - tokio::time::timeout(Duration::from_millis(300), parent_handler.cancel()) + parent_handler + .cancel() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handler.done()) + handler + .done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); @@ -179,14 +212,18 @@ async fn test_context_cancel_cloned() { assert_eq!(reason, CancelReason::Cancel); }); - tokio::time::timeout(Duration::from_millis(300), handler.cancel()) + handler + .cancel() + .timeout(Duration::from_millis(300)) .await .expect_err("task should block because a clone exists"); - tokio::time::timeout(Duration::from_millis(300), handle) + handle + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled") .expect("panic in task"); - tokio::time::timeout(Duration::from_millis(300), ctx2.done()) + ctx2.done() + .timeout(Duration::from_millis(300)) .await .expect("task should be cancelled"); } diff --git a/common/src/tests/grpc.rs b/common/src/tests/grpc.rs new file mode 100644 index 00000000..34f5945b --- /dev/null +++ b/common/src/tests/grpc.rs @@ -0,0 +1,566 @@ +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use tokio::sync::Mutex; +use tonic::transport::Server; +use trust_dns_resolver::{ + error::ResolveError, + lookup::Lookup, + proto::{ + op::Query, + rr::{RData, Record, RecordType}, + }, + Name, +}; + +use crate::grpc::{ + make_channel, make_channel_with_opts, make_channel_with_resolver, ChannelOpts, DnsResolver, +}; + +mod pb { + tonic::include_proto!("test"); +} + +struct TestImpl { + name: String, +} + +#[async_trait] +impl pb::test_server::Test for TestImpl { + async fn test( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(pb::TestResponse { + message: request.into_inner().message, + server: self.name.clone(), + })) + } +} + +#[tokio::test] +async fn test_static_ip_resolve() { + let addr_1 = SocketAddr::from(( + [127, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr_1), + ); + + let addr_2 = SocketAddr::from(( + [127, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server2".to_string(), + })) + .serve(addr_2), + ); + + let channel = make_channel( + vec![addr_1.to_string(), addr_2.to_string()], + Duration::from_secs(0), + None, + ) + .unwrap(); + let mut client = pb::test_client::TestClient::new(channel); + + let mut server_1 = 0; + let mut server_2 = 0; + + const NUM_REQUESTS: usize = 1000; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else if response.server == "server2" { + server_2 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 50/50 + // If it's not, then the load balancer is not working + // This allows for a 10% error margin + assert!(server_1 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert!(server_2 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert_eq!(server_1 + server_2, NUM_REQUESTS); +} + +#[tokio::test] +async fn test_dns_resolve_v4() { + let addr_1 = SocketAddr::from(( + [127, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr_1), + ); + + let addr_2 = SocketAddr::from(( + [127, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server2".to_string(), + })) + .serve(addr_2), + ); + + let channel = make_channel_with_opts(ChannelOpts { + addresses: vec![ + format!("localhost:{}", addr_1.port()), + format!("localhost:{}", addr_2.port()), + ], + try_cname: false, + enable_ipv6: false, + enable_ipv4: true, + interval: Duration::from_secs(0), + tls: None, + }) + .unwrap(); + let mut client = pb::test_client::TestClient::new(channel); + + let mut server_1 = 0; + let mut server_2 = 0; + + const NUM_REQUESTS: usize = 1000; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else if response.server == "server2" { + server_2 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 50/50 + // If it's not, then the load balancer is not working + // This allows for a 10% error margin + assert!(server_1 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert!(server_2 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert_eq!(server_1 + server_2, NUM_REQUESTS); +} + +#[tokio::test] +async fn test_dns_resolve_v6() { + let addr_1 = SocketAddr::from(( + [0, 0, 0, 0, 0, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr_1), + ); + + let addr_2 = SocketAddr::from(( + [0, 0, 0, 0, 0, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server2".to_string(), + })) + .serve(addr_2), + ); + + let channel = make_channel_with_opts(ChannelOpts { + addresses: vec![ + format!("localhost:{}", addr_1.port()), + format!("localhost:{}", addr_2.port()), + ], + try_cname: false, + enable_ipv6: true, + enable_ipv4: false, + interval: Duration::from_secs(0), + tls: None, + }) + .unwrap(); + let mut client = pb::test_client::TestClient::new(channel); + + let mut server_1 = 0; + let mut server_2 = 0; + + const NUM_REQUESTS: usize = 1000; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else if response.server == "server2" { + server_2 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 50/50 + // If it's not, then the load balancer is not working + // This allows for a 10% error margin + assert!(server_1 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert!(server_2 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert_eq!(server_1 + server_2, NUM_REQUESTS); +} + +#[tokio::test] +async fn test_dns_resolve_cname() { + struct Dns; + + #[async_trait] + impl DnsResolver for Dns { + async fn lookup( + &self, + hostname: &str, + record_type: RecordType, + ) -> Result { + assert_eq!(hostname, "localhost"); + assert_eq!(record_type, RecordType::CNAME); + + Ok(Lookup::new_with_max_ttl( + Query::new(), + Arc::from([Record::from_rdata( + Name::default(), + 0, + RData::CNAME(Name::from_utf8("localhost").unwrap()), + )]), + )) + } + } + + let addr = SocketAddr::from(( + [127, 0, 0, 1], + portpicker::pick_unused_port().expect("failed to pick port"), + )); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr), + ); + + let channel = make_channel_with_resolver( + Dns, + ChannelOpts { + addresses: vec![format!("localhost:{}", addr.port())], + enable_ipv4: false, + enable_ipv6: false, + try_cname: true, + interval: Duration::from_millis(0), + tls: None, + }, + ) + .unwrap(); + + let mut client = pb::test_client::TestClient::new(channel); + + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + assert_eq!(response.server, "server1"); +} + +#[tokio::test] +async fn test_headless_dns_resolve() { + struct Dns { + addresses: Vec, + } + + #[async_trait] + impl DnsResolver for Dns { + async fn lookup( + &self, + hostname: &str, + record_type: RecordType, + ) -> Result { + assert_eq!(hostname, "localhost"); + assert_eq!(record_type, RecordType::CNAME); + + let records = self + .addresses + .iter() + .map(|addr| { + Record::from_rdata( + Name::default(), + 0, + match addr.ip() { + IpAddr::V4(addr) => RData::A(addr), + IpAddr::V6(addr) => RData::AAAA(addr), + }, + ) + }) + .collect::>(); + + Ok(Lookup::new_with_max_ttl(Query::new(), Arc::from(records))) + } + } + + let port = portpicker::pick_unused_port().expect("failed to pick port"); + + let addr_1 = SocketAddr::from(([127, 0, 0, 1], port)); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr_1), + ); + + let addr_2 = SocketAddr::from(([127, 0, 0, 2], port)); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server2".to_string(), + })) + .serve(addr_2), + ); + + let resolver = Dns { + addresses: vec![addr_1, addr_2], + }; + + let channel = make_channel_with_resolver( + resolver, + ChannelOpts { + addresses: vec![format!("localhost:{}", port)], + enable_ipv4: true, + enable_ipv6: true, + try_cname: true, + interval: Duration::from_secs(0), + tls: None, + }, + ) + .unwrap(); + let mut client = pb::test_client::TestClient::new(channel); + + let mut server_1 = 0; + let mut server_2 = 0; + + const NUM_REQUESTS: usize = 1000; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else if response.server == "server2" { + server_2 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 50/50 + // If it's not, then the load balancer is not working + // This allows for a 10% error margin + assert!(server_1 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert!(server_2 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert_eq!(server_1 + server_2, NUM_REQUESTS); +} + +#[tokio::test] +async fn test_dns_resolve_change() { + struct Dns { + addresses: Arc>>, + } + + #[async_trait] + impl DnsResolver for Dns { + async fn lookup( + &self, + hostname: &str, + record_type: RecordType, + ) -> Result { + assert_eq!(hostname, "localhost"); + assert_eq!(record_type, RecordType::CNAME); + + let records = self + .addresses + .lock() + .await + .iter() + .map(|addr| { + Record::from_rdata( + Name::default(), + 0, + match addr.ip() { + IpAddr::V4(addr) => RData::A(addr), + IpAddr::V6(addr) => RData::AAAA(addr), + }, + ) + }) + .collect::>(); + + Ok(Lookup::new_with_max_ttl(Query::new(), Arc::from(records))) + } + } + + let port = portpicker::pick_unused_port().expect("failed to pick port"); + + let addr_1 = SocketAddr::from(([127, 0, 0, 1], port)); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server1".to_string(), + })) + .serve(addr_1), + ); + + let addr_2 = SocketAddr::from(([127, 0, 0, 2], port)); + tokio::spawn( + Server::builder() + .add_service(pb::test_server::TestServer::new(TestImpl { + name: "server2".to_string(), + })) + .serve(addr_2), + ); + + let addresses = Arc::new(Mutex::new(vec![addr_1, addr_2])); + + let resolver = Dns { + addresses: addresses.clone(), + }; + + let channel = make_channel_with_resolver( + resolver, + ChannelOpts { + addresses: vec![format!("localhost:{}", port)], + enable_ipv4: true, + enable_ipv6: true, + try_cname: true, + interval: Duration::from_millis(100), // very fast poll interval + tls: None, + }, + ) + .unwrap(); + let mut client = pb::test_client::TestClient::new(channel); + + let mut server_1 = 0; + let mut server_2 = 0; + + const NUM_REQUESTS: usize = 1000; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else if response.server == "server2" { + server_2 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 50/50 + // If it's not, then the load balancer is not working + // This allows for a 10% error margin + assert!(server_1 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert!(server_2 > NUM_REQUESTS / 2 - NUM_REQUESTS / 10); + assert_eq!(server_1 + server_2, NUM_REQUESTS); + + // Now remove the second server + addresses.lock().await.remove(1); + + // Wait for the server to be removed + tokio::time::sleep(Duration::from_millis(150)).await; + + let mut server_1 = 0; + + for _ in 0..NUM_REQUESTS { + let response = client + .test(tonic::Request::new(pb::TestRequest { + message: "test".to_string(), + })) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.message, "test"); + + if response.server == "server1" { + server_1 += 1; + } else { + panic!("unknown server"); + } + } + + // The distribution is not perfect, but it should be close to 100/0 + // If it's not, then the load balancer is not working + assert_eq!(server_1, NUM_REQUESTS); +} diff --git a/common/src/tests/logging.rs b/common/src/tests/logging.rs index 37104986..9cfaa640 100644 --- a/common/src/tests/logging.rs +++ b/common/src/tests/logging.rs @@ -2,10 +2,5 @@ use crate::logging::init; #[test] fn test_init() { - init("info").expect("Failed to init logger"); -} - -#[test] -fn test_with_bad_input() { - init("???").expect("Failed to init logger"); + init("info", false).expect("Failed to init logger"); } diff --git a/common/src/tests/mod.rs b/common/src/tests/mod.rs index 152924f4..e54ef4d2 100644 --- a/common/src/tests/mod.rs +++ b/common/src/tests/mod.rs @@ -1,5 +1,10 @@ +#[cfg(feature = "config")] mod config; +#[cfg(feature = "context")] mod context; +#[cfg(feature = "grpc")] +mod grpc; +#[cfg(feature = "logging")] mod logging; +#[cfg(feature = "signal")] mod signal; -mod types; diff --git a/common/src/tests/signal.rs b/common/src/tests/signal.rs index 3757bd0f..95f3dff7 100644 --- a/common/src/tests/signal.rs +++ b/common/src/tests/signal.rs @@ -2,7 +2,7 @@ use std::time::Duration; use tokio::{process::Command, signal::unix::SignalKind}; -use crate::signal::SignalHandler; +use crate::{prelude::FutureTimeout, signal::SignalHandler}; #[tokio::test] async fn test_signal() { @@ -22,7 +22,9 @@ async fn test_signal() { .await .expect("failed to send SIGINT"); - tokio::time::timeout(Duration::from_secs(1), handler.recv()) + handler + .recv() + .timeout(Duration::from_secs(1)) .await .expect("failed to receive signal"); @@ -35,7 +37,9 @@ async fn test_signal() { .await .expect("failed to send SIGINT"); - tokio::time::timeout(Duration::from_secs(1), handler.recv()) + handler + .recv() + .timeout(Duration::from_secs(1)) .await .expect("failed to receive signal"); } diff --git a/common/src/tests/types/mod.rs b/common/src/tests/types/mod.rs deleted file mode 100644 index 0eba1100..00000000 --- a/common/src/tests/types/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod user; diff --git a/common/src/types/channel.rs b/common/src/types/channel.rs deleted file mode 100644 index 9cbdce69..00000000 --- a/common/src/types/channel.rs +++ /dev/null @@ -1,14 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub owner_id: i64, // bigint, foreign key -> users.id - pub name: String, // varchar(32) - pub description: String, // text - pub stream_key: String, // char(25) - pub chat_room_id: Option, // bigint?, foreign key -> chat_rooms.id - pub last_live: Option>, // timestamptz? - pub created_at: DateTime, // timestamptz - pub deleted_at: Option>, // timestamptz? -} diff --git a/common/src/types/channel_ban.rs b/common/src/types/channel_ban.rs deleted file mode 100644 index ac39f818..00000000 --- a/common/src/types/channel_ban.rs +++ /dev/null @@ -1,21 +0,0 @@ -use bitmask_enum::bitmask; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub owner_id: i64, // bigint, foreign key -> users.id - pub target_id: i64, // bigint, foreign key -> users.id - pub channel_id: Option, // bigint?, foreign key -> channels.id - pub mode: i64, // bigint, bitfield -> Mode - pub reason: String, // varchar(255) - pub expires_at: Option>, // timestamptz? - pub created_at: DateTime, // timestamptz -} - -#[bitmask(i64)] -pub enum Mode { - ChatBan, // User is unable to type in chat - ReadBan, // User is unable to read chat - WatchBan, // User is unable to watch the channel -} diff --git a/common/src/types/channel_role.rs b/common/src/types/channel_role.rs deleted file mode 100644 index 98931683..00000000 --- a/common/src/types/channel_role.rs +++ /dev/null @@ -1,32 +0,0 @@ -use bitmask_enum::bitmask; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub owner_id: i64, // bigint, foreign key -> users.id - pub channel_id: Option, // bigint?, foreign key -> channels.id - pub name: String, // varchar(32) - pub description: String, // text - pub rank: i32, // int, 0 is lowest rank, 255 is highest rank - pub allowed_permissions: i64, // bigint, bitmask of permissions - pub denied_permissions: i64, // bigint, bitmask of permissions - pub created_at: DateTime, // timestamptz -} - -#[bitmask(i64)] -pub enum Permission { - View, // Can view this channel - Watch, // Can watch videos on this channel - Read, // Can read chat rooms on this channel - Talk, // Can talk in chat rooms on this channel - EditChannel, // Can edit this channel channel - DeleteChannel, // Can delete this channel channel - GoLive, // Can go live on this channel channel - ChatRoomBypass, // Can bypass chat room restrictions on their own channel (follow only, sub only, cannot be banned from chat rooms, ect) - ChatRoomModerate, // Can moderate chat rooms on this channel - ChatRoomManage, // Can create/edit/delete chat rooms on this channel - ManageUsers, // Can ban/unban users on this channel - GrantRoles, // Can grant roles to users on this channel channel - ManageRoles, // Can create/edit/delete roles on this channel -} diff --git a/common/src/types/channel_role_grant.rs b/common/src/types/channel_role_grant.rs deleted file mode 100644 index eb2935ff..00000000 --- a/common/src/types/channel_role_grant.rs +++ /dev/null @@ -1,9 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub user_id: i64, // bigint, foreign key -> users.id - pub channel_role_id: i64, // bigint, foreign key -> channel_roles.id - pub created_at: DateTime, // timestamptz -} diff --git a/common/src/types/chat_message.rs b/common/src/types/chat_message.rs deleted file mode 100644 index e23534d4..00000000 --- a/common/src/types/chat_message.rs +++ /dev/null @@ -1,10 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub chat_room_id: i64, // bigint, foreign key -> chat_rooms.id - pub author_id: i64, // bigint, foreign key -> users.id - pub message: String, // text - pub created_at: DateTime, // timestamptz -} diff --git a/common/src/types/chat_room.rs b/common/src/types/chat_room.rs deleted file mode 100644 index 452aad56..00000000 --- a/common/src/types/chat_room.rs +++ /dev/null @@ -1,11 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub owner_id: i64, // bigint, foreign key -> users.id - pub name: String, // varchar(32) - pub description: String, // text - pub deleted_at: Option>, // timestamptz? - pub created_at: DateTime, // timestamptz -} diff --git a/common/src/types/follow.rs b/common/src/types/follow.rs deleted file mode 100644 index 19852582..00000000 --- a/common/src/types/follow.rs +++ /dev/null @@ -1,10 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub follower_id: i64, // bigint, foreign key -> users.id - pub followed_id: i64, // bigint, foreign key -> users.id - pub channel_id: Option, // bigint, foreign key -> channels.id - pub created_at: DateTime, // timestamptz -} diff --git a/common/src/types/global_ban.rs b/common/src/types/global_ban.rs deleted file mode 100644 index 0b89e363..00000000 --- a/common/src/types/global_ban.rs +++ /dev/null @@ -1,21 +0,0 @@ -use bitmask_enum::bitmask; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub user_id: i64, // bigint, foreign key -> users.id - pub mode: i64, // bigint, bitfield -> Mode - pub reason: String, // text - pub expires_at: Option>, // timestamptz? - pub created_at: DateTime, // timestamptz -} - -#[bitmask(i64)] -pub enum Mode { - SiteBan, // User is unable to login (implies all other bans) - ChatBan, // User is unable to type in chat - LiveBan, // User is banned from going live - ReportBan, // User is banned from using the report system - SupportBan, // User is banned from using the support system -} diff --git a/common/src/types/global_role.rs b/common/src/types/global_role.rs deleted file mode 100644 index 32c92d8c..00000000 --- a/common/src/types/global_role.rs +++ /dev/null @@ -1,24 +0,0 @@ -use bitmask_enum::bitmask; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub name: String, // varchar(32) - pub description: String, // text - pub rank: i32, // int - pub allowed_permissions: i64, // bigint, bitfield -> Permission - pub denied_permissions: i64, // bigint, bitfield -> Permission - pub created_at: DateTime, // timestamptz -} - -#[bitmask(i64)] -pub enum Permission { - UseChannels, // Can create their own channels, edit their own channels, delete their own channels - GoLive, // Can go live on their own channels - ChatRoomBypass, // Can bypass chat room restrictions globally (follow only, sub only, cannot be banned from chat rooms, ect) - ManageUsers, // Can ban/unban users. - ManageChannels, // Can edit/delete any channel, can create channels for other users - GrantRoles, // Can grant roles to users - ManageRoles, // Can create/edit/delete roles -} diff --git a/common/src/types/global_role_grant.rs b/common/src/types/global_role_grant.rs deleted file mode 100644 index fe8c500e..00000000 --- a/common/src/types/global_role_grant.rs +++ /dev/null @@ -1,9 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub user_id: i64, // bigint, foreign key -> users.id - pub global_role_id: i64, // bigint, foreign key -> global_roles.id - pub created_at: DateTime, // timestamptz -} diff --git a/common/src/types/session.rs b/common/src/types/session.rs deleted file mode 100644 index da0059dc..00000000 --- a/common/src/types/session.rs +++ /dev/null @@ -1,25 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub user_id: i64, // bigint, foreign key -> users.id - pub invalidated_at: Option>, // timestampz - pub created_at: DateTime, // timestamptz - pub expires_at: DateTime, // timestamptz - pub last_used_at: DateTime, // timestamptz -} - -impl Model { - pub fn is_valid(&self) -> bool { - if self.invalidated_at.is_some() { - return false; - } - - if self.expires_at < Utc::now() { - return false; - } - - true - } -} diff --git a/common/src/types/stream.rs b/common/src/types/stream.rs deleted file mode 100644 index a46c8a3d..00000000 --- a/common/src/types/stream.rs +++ /dev/null @@ -1,12 +0,0 @@ -use chrono::{DateTime, Utc}; - -#[derive(Debug, Clone, Default)] -pub struct Model { - pub id: i64, // bigint, primary key - pub channel_id: i64, // bigint, foreign key -> channels.id - pub title: String, // varchar(255) - pub description: String, // text - pub created_at: DateTime, // timestamptz - pub started_at: Option>, // timestamptz? - pub ended_at: Option>, // timestamptz? -} diff --git a/dev-stack/db.docker-compose.yml b/dev-stack/db.docker-compose.yml index 9746fb77..247518d4 100644 --- a/dev-stack/db.docker-compose.yml +++ b/dev-stack/db.docker-compose.yml @@ -3,25 +3,26 @@ version: "3.1" name: "db-scuffle-dev" services: - postgres: - image: postgres:15.2 + cockroach: + image: ghcr.io/scuffletv/cockroach:latest ports: - - "5432:5432" + - "5432:26257" + - "8080:8080" + + rmq: + image: bitnami/rabbitmq:latest environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - pgadmin: - image: dpage/pgadmin4:6.20 + RABBITMQ_USERNAME: rabbitmq + RABBITMQ_PASSWORD: rabbitmq + RABBITMQ_VHOSTS: scuffle ports: - - "5050:80" - environment: - PGADMIN_DEFAULT_EMAIL: admin@admin.com - PGADMIN_DEFAULT_PASSWORD: admin - depends_on: - - postgres - volumes: - - ./pgadmin-servers.json:/pgadmin4/servers.json + - "5672:5672" + - "15672:15672" + + redis: + image: redis:latest + ports: + - "6379:6379" networks: default: diff --git a/dev-stack/example.docker-compose.yml b/dev-stack/example.docker-compose.yml deleted file mode 100644 index 2716f105..00000000 --- a/dev-stack/example.docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: "3.1" - -name: "stack-scuffle-dev" - -services: - api: - build: - context: .. - dockerfile: docker/api.Dockerfile - ports: - - "8080:8080" - environment: - - SCUF_DATABASE_URL=postgres://postgres:postgres@postgres:5432/scuffle-dev - website: - build: - context: .. - dockerfile: docker/website.Dockerfile - ports: - - "4000:4000" - edge: - build: - context: .. - dockerfile: docker/edge.Dockerfile - ingest: - build: - context: .. - dockerfile: docker/ingest.Dockerfile - transcoder: - build: - context: .. - dockerfile: docker/transcoder.Dockerfile - -networks: - default: - name: scuffle-dev - external: true diff --git a/docker/cve.sh b/docker/cve.sh index 8047ec35..ff2feb3e 100755 --- a/docker/cve.sh +++ b/docker/cve.sh @@ -1,11 +1,13 @@ +set -e + apt-get update apt-get install -y --no-install-recommends \ - libgnutls30=3.7.3-4ubuntu1.2 \ - libssl3=3.0.2-0ubuntu1.8 \ - libsystemd0=249.11-0ubuntu3.7 \ - libudev1=249.11-0ubuntu3.7 \ - tar=1.34+dfsg-1ubuntu0.1.22.04.1 \ - ca-certificates \ + libgnutls30 \ + libssl3 \ + libsystemd0 \ + libudev1 \ + tar \ + ca-certificates rm -rf /var/lib/apt/lists/* diff --git a/docker/website.Dockerfile b/docker/website.Dockerfile index bdbfae2d..300f9cb2 100644 --- a/docker/website.Dockerfile +++ b/docker/website.Dockerfile @@ -1,10 +1,10 @@ -FROM denoland/deno:alpine-1.30.3 +FROM denoland/deno:alpine LABEL org.opencontainers.image.source=https://github.com/scuffletv/scuffle LABEL org.opencontainers.image.description="Website Container for ScuffleTV" LABEL org.opencontainers.image.licenses=BSD-4-Clause -RUN apk add --no-cache libssl3=3.0.8-r3 libcrypto3=3.0.8-r3 +RUN apk add --upgrade libcrypto3 libssl3 --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community COPY frontend/website/server.ts /app/ COPY frontend/website/build /app/build diff --git a/frontend/player/.eslintignore b/frontend/player/.eslintignore new file mode 100644 index 00000000..8d9a28a2 --- /dev/null +++ b/frontend/player/.eslintignore @@ -0,0 +1,16 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +pkg/ +target/ +dist/ diff --git a/frontend/player/.eslintrc.cjs b/frontend/player/.eslintrc.cjs new file mode 100644 index 00000000..5ebc32b8 --- /dev/null +++ b/frontend/player/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], + plugins: ["@typescript-eslint"], + ignorePatterns: ["*.cjs", "src/gql/*"], + globals: { + NodeJS: true, + }, + parserOptions: { + sourceType: "module", + ecmaVersion: 2020, + }, + env: { + browser: true, + es2017: true, + node: true, + }, +}; diff --git a/frontend/player/.gitignore b/frontend/player/.gitignore index 4e301317..f9bcc096 100644 --- a/frontend/player/.gitignore +++ b/frontend/player/.gitignore @@ -1,6 +1,26 @@ -/target -**/*.rs.bk -Cargo.lock -bin/ -pkg/ -wasm-pack.log +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +wasm.d.ts +demo-dist diff --git a/frontend/player/.npmrc b/frontend/player/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/frontend/player/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/player/.prettierignore b/frontend/player/.prettierignore new file mode 100644 index 00000000..09acb51f --- /dev/null +++ b/frontend/player/.prettierignore @@ -0,0 +1,19 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +src/gql +schema.graphql + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +wasm.d.ts +target/ +dist/ +pkg/ diff --git a/frontend/player/.prettierrc b/frontend/player/.prettierrc new file mode 100644 index 00000000..798f26f1 --- /dev/null +++ b/frontend/player/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "plugins": [], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svg", "options": { "parser": "html" } }] +} diff --git a/frontend/player/Cargo.lock b/frontend/player/Cargo.lock new file mode 100644 index 00000000..15390943 --- /dev/null +++ b/frontend/player/Cargo.lock @@ -0,0 +1,997 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aac" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "num-derive", + "num-traits", +] + +[[package]] +name = "anyhow" +version = "1.0.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "av1" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", +] + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "base64" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1e31e207a6b8fb791a38ea3105e6cb541f55e4d029902d3039a4ad07cc4105" + +[[package]] +name = "bumpalo" +version = "3.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6ed94e98ecff0c12dd1b04c15ec0d7d9458ca8fe806cea6f12954efe74c63b" + +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "bytesio" +version = "0.0.1" +dependencies = [ + "byteorder", + "bytes", +] + +[[package]] +name = "casey" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614586263949597dcc18675da12ef9b429135e13628d92eb8b8c6fa50ca5656b" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "exp_golomb" +version = "0.1.0" +dependencies = [ + "bytes", + "bytesio", +] + +[[package]] +name = "fixed" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79386fdcec5e0fde91b1a6a5bcd89677d1f9304f7f986b154a1b9109038854d9" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8fc851e9c7b9852508bc6e3f690f452f474417e8545ec9857b7f7377036b5" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "h264" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "exp_golomb", +] + +[[package]] +name = "h265" +version = "0.1.0" +dependencies = [ + "byteorder", + "bytes", + "bytesio", + "exp_golomb", +] + +[[package]] +name = "half" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4af3693f1b705df946e9fe5631932443781d0aabb423b62fcd4d73f6d2fd0" +dependencies = [ + "crunchy", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "js-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mp4" +version = "0.0.1" +dependencies = [ + "aac", + "av1", + "byteorder", + "bytes", + "bytesio", + "casey", + "fixed", + "h264", + "h265", + "paste", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "paste" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "player" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "bytes", + "bytesio", + "console_error_panic_hook", + "futures", + "gloo-timers", + "h264", + "js-sys", + "mp4", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "tsify", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa1fb82fc0c281dd9671101b66b771ebbe1eaf967b96ac8740dcba4b70005ca8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "serde" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa32867d44e6f2ce3385e89dceb990188b8bb0fb25b0cf576647a6f98ac5105" +dependencies = [ + "autocfg", + "pin-project-lite", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tsify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfafeca19605f1239ad9a30b9f15b58e53abedfbf237390506276029329a32ee" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d637b54262b1c6b8c2c88ce30fbef4e72613cb71c0e0b6fc09747052eb59a152" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "wasm-bindgen" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.16", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1985d03709c53167ce907ff394f5316aa22cb4e12761295c5dc57dacb6297e" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e636f3a428ff62b3742ebc3c70e254dfe12b8c2b469d688ea59cdd4abcf502" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "scoped-tls", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18c1fad2f7c4958e7bcce014fa212f59a65d5e3721d0f77e6c0b27ede936ba3" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "web-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/frontend/player/Cargo.toml b/frontend/player/Cargo.toml index 98d0b55a..4f672f3b 100644 --- a/frontend/player/Cargo.toml +++ b/frontend/player/Cargo.toml @@ -1,17 +1,76 @@ +# You must change these to your own details. [package] name = "player" +description = "Scuffle Video Player" version = "0.1.0" +authors = ["Troy Benson "] +categories = ["wasm"] +readme = "README.md" +license = "LICENSE.md" edition = "2021" -authors = ["Scuffle "] -description = "Scuffle Video Player" + +[profile.release] +lto = true +opt-level = "z" +codegen-units = 1 +strip = true +panic = "abort" [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["cdylib"] [dependencies] -wasm-bindgen = "0.2.63" -console_error_panic_hook = { version = "0.1.6" } -tracing-web = "0.1.2" -tracing = { version = "0.1.37", default-features = false } -tracing-subscriber = { version = "0.3.15", default-features = false, features = ["fmt", "time", "ansi"] } -time = { version = "0.3.19", features = ["wasm-bindgen"] } +wasm-bindgen = "0" +console_error_panic_hook = "0" +tracing = "0" +tracing-subscriber = "0" +bytes = "1" +anyhow = "1" +wasm-bindgen-futures = "0" +gloo-timers = { version = "0", features = ["futures"] } +js-sys = "0" +url = { version = "2", features = ["serde"] } +futures = "0" +tokio = { version = "1", features = ["sync", "macros"] } +tokio-stream = "0" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0" +tsify = "0" +serde_json = "1" +h264 = { path = "../../video/codec/h264" } +base64 = "0" + +bytesio = { path = "../../video/bytesio", default-features = false } +mp4 = { path = "../../video/container/mp4" } + +[dependencies.web-sys] +version = "0" +features = [ + "console", + "Headers", + "Request", + "RequestInit", + "RequestMode", + "Response", + "Window", + "HtmlVideoElement", + "MediaSource", + "SourceBuffer", + "Url", + "MediaSourceReadyState", + "Event", + "ErrorEvent", + "TimeRanges", + "HtmlMediaElement", + "MediaStream", + "Performance", + "Window", + "XmlHttpRequest", + "XmlHttpRequestResponseType", +] + +[dev-dependencies] +wasm-bindgen-test = "0" +futures = "0" +js-sys = "0" +wasm-bindgen-futures = "0" diff --git a/frontend/player/demo/index.ts b/frontend/player/demo/index.ts new file mode 100644 index 00000000..198bb872 --- /dev/null +++ b/frontend/player/demo/index.ts @@ -0,0 +1,115 @@ +import { init, Player } from "../js/main"; + +await init(); + +const player = new Player(); + +console.log(player); + +const video = document.getElementById("video") as HTMLVideoElement; +const bufferSize = document.getElementById("buffer-size") as HTMLElement; +const videoTime = document.getElementById("video-time") as HTMLElement; +const frameRate = document.getElementById("frame-rate") as HTMLElement; + +const selectTrack0 = document.getElementById("select-track-0") as HTMLButtonElement; +const selectTrack1 = document.getElementById("select-track-1") as HTMLButtonElement; +const selectTrack2 = document.getElementById("select-track-2") as HTMLButtonElement; +const selectTrack3 = document.getElementById("select-track-3") as HTMLButtonElement; +const selectTrack4 = document.getElementById("select-track-4") as HTMLButtonElement; + +const forceTrack0 = document.getElementById("force-track-0") as HTMLButtonElement; +const forceTrack1 = document.getElementById("force-track-1") as HTMLButtonElement; +const forceTrack2 = document.getElementById("force-track-2") as HTMLButtonElement; +const forceTrack3 = document.getElementById("force-track-3") as HTMLButtonElement; +const forceTrack4 = document.getElementById("force-track-4") as HTMLButtonElement; + +const toggleLowLatency = document.getElementById("toggle-low-latency") as HTMLButtonElement; +const jumpToLive = document.getElementById("jump-to-live") as HTMLButtonElement; + +let lastFrameTime = 0; +let frameCount = 0; + +selectTrack0.addEventListener("click", () => { + player.nextTrackId = 0; +}); + +selectTrack1.addEventListener("click", () => { + player.nextTrackId = 1; +}); + +selectTrack2.addEventListener("click", () => { + player.nextTrackId = 2; +}); + +selectTrack3.addEventListener("click", () => { + player.nextTrackId = 3; +}); + +selectTrack4.addEventListener("click", () => { + player.nextTrackId = 4; +}); + +forceTrack0.addEventListener("click", () => { + player.forceTrackId = 0; +}); + +forceTrack1.addEventListener("click", () => { + player.forceTrackId = 1; +}); + +forceTrack2.addEventListener("click", () => { + player.forceTrackId = 2; +}); + +forceTrack3.addEventListener("click", () => { + player.forceTrackId = 3; +}); + +forceTrack4.addEventListener("click", () => { + player.forceTrackId = 4; +}); + +toggleLowLatency.addEventListener("click", () => { + player.lowLatency = !player.lowLatency; +}); + +jumpToLive.addEventListener("click", () => { + if (player.lowLatency) { + video.currentTime = video.buffered.end(video.buffered.length - 1) - 0.5; + } else { + video.currentTime = video.buffered.end(video.buffered.length - 1) - 2; + } +}); + +video.addEventListener("timeupdate", () => { + bufferSize.innerText = video.buffered.end(video.buffered.length - 1) - video.currentTime + ""; + videoTime.innerText = video.currentTime + ""; + + const quality = video.getVideoPlaybackQuality(); + + const now = performance.now(); + if (now - lastFrameTime > 1000) { + frameRate.innerText = quality.totalVideoFrames - frameCount + ""; + frameCount = quality.totalVideoFrames; + lastFrameTime = now; + } +}); + +player.lowLatency = false; + +player.onerror = (evt) => { + console.error(evt); +}; + +player.onmanifestloaded = (evt) => { + console.log(evt); +}; + +player.load( + // "http://192.168.2.177:9080/4f75cb30-6acf-4b1f-a91d-d9ae2c72c0cd/master.m3u8", + "https://troy-edge.scuffle.tv/4f75cb30-6acf-4b1f-a91d-d9ae2c72c0cd/master.m3u8", + // "http://192.168.2.177:9080/51636c0f-a2f1-46d6-9da1-07b386efff7a/03f92acb-fd92-4fb5-9023-5e27b82ba987/index.m3u8", + // "http://192.168.2.177:9080/4def6aa7-6ae2-4a35-a473-d346de345e54/041c0b21-972d-4992-aca5-f010c01067c5/index.m3u8", +); + +await player.attach(video); diff --git a/frontend/player/index.html b/frontend/player/index.html new file mode 100644 index 00000000..44c1c984 --- /dev/null +++ b/frontend/player/index.html @@ -0,0 +1,47 @@ + + + + + + Demo Player + + +
+ +
+

Select

+ + + + + +
+
+

Force

+ + + + + +
+
+

Toggle

+ + +
+

Video Time

+

Buffer Size

+

Frame Rate

+
+ + + + diff --git a/frontend/player/js/main.ts b/frontend/player/js/main.ts new file mode 100644 index 00000000..db65fdb8 --- /dev/null +++ b/frontend/player/js/main.ts @@ -0,0 +1,4 @@ +import initFn from "../pkg/player"; + +export * from "../pkg/player"; +export const init = initFn; diff --git a/frontend/player/maskfile.md b/frontend/player/maskfile.md deleted file mode 100644 index db50d351..00000000 --- a/frontend/player/maskfile.md +++ /dev/null @@ -1,48 +0,0 @@ -# Frontend Player Tasks - -## build - -> Build the project - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -cd $MASKFILE_DIR - -wasm-pack build --target web --out-name player --out-dir ./pkg --release -``` - -### dev - -> Run the project in development mode - -**OPTIONS** - -- watch - - flags: --watch - - desc: Watch for changes and rebuild - -```bash -cd $MASKFILE_DIR - -wasm-pack build --target web --out-name player --out-dir ./pkg --dev - -if [ "$watch" == "true" ]; then - while true; do - cargo-watch -q --postpone -s "wasm-pack build --target web --out-name player --out-dir ./pkg --dev" - done -fi -``` - -## clean - -> Clean the project - -```bash -cd $MASKFILE_DIR - -rm -rf pkg -``` diff --git a/frontend/player/package.json b/frontend/player/package.json new file mode 100644 index 00000000..037fdf34 --- /dev/null +++ b/frontend/player/package.json @@ -0,0 +1,36 @@ +{ + "name": "@scuffle/player", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "wasm:build": "wasm-pack build . --target web --weak-refs --reference-types", + "wasm:watch": "cargo watch --watch src --watch Cargo.toml -s \"pnpm run wasm:build --dev\"", + "watch": "pnpm run wasm:watch & vite build --watch", + "build": "pnpm run clean && pnpm run wasm:build --release && tsc && vite build && vite build -c vite.demo.config.ts", + "build:dev": "pnpm run clean && pnpm run wasm:build --dev && tsc && vite build && vite build -c vite.demo.config.ts", + "lint": "prettier --plugin-search-dir . --check \"**/*\" -u && eslint . --ext .js,.ts && cargo fmt --check && cargo clippy -- -D warnings", + "format": "prettier --plugin-search-dir . --write \"**/*\" -u && cargo fmt && cargo clippy --fix --allow-dirty --allow-staged", + "demo:dev": "pnpm run wasm:watch & vite", + "demo:build": "pnpm run wasm:build --release && tsc && vite build -c vite.demo.config.ts", + "demo:preview": "vite preview", + "clean": "rimraf dist pkg" + }, + "module": "./dist/player.js", + "types": "./dist/player.d.ts", + "files": [ + "dist" + ], + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.59.2", + "@typescript-eslint/parser": "^5.59.2", + "astring": "^1.8.4", + "eslint": "^8.40.0", + "eslint-config-prettier": "^8.8.0", + "prettier": "^2.8.8", + "rimraf": "^3.0.0", + "typescript": "^5.0.2", + "vite": "^4.3.2", + "vite-plugin-dts": "^2.3.0" + } +} diff --git a/frontend/player/pnpm-lock.yaml b/frontend/player/pnpm-lock.yaml new file mode 100644 index 00000000..17478d66 --- /dev/null +++ b/frontend/player/pnpm-lock.yaml @@ -0,0 +1,1670 @@ +lockfileVersion: '6.0' + +devDependencies: + '@typescript-eslint/eslint-plugin': + specifier: ^5.59.2 + version: 5.59.2(@typescript-eslint/parser@5.59.2)(eslint@8.40.0)(typescript@5.0.2) + '@typescript-eslint/parser': + specifier: ^5.59.2 + version: 5.59.2(eslint@8.40.0)(typescript@5.0.2) + astring: + specifier: ^1.8.4 + version: 1.8.4 + eslint: + specifier: ^8.40.0 + version: 8.40.0 + eslint-config-prettier: + specifier: ^8.8.0 + version: 8.8.0(eslint@8.40.0) + prettier: + specifier: ^2.8.8 + version: 2.8.8 + rimraf: + specifier: ^3.0.0 + version: 3.0.0 + typescript: + specifier: ^5.0.2 + version: 5.0.2 + vite: + specifier: ^4.3.2 + version: 4.3.2 + vite-plugin-dts: + specifier: ^2.3.0 + version: 2.3.0(vite@4.3.2) + +packages: + + /@babel/helper-string-parser@7.21.5: + resolution: {integrity: sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.19.1: + resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/parser@7.21.8: + resolution: {integrity: sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/types@7.21.5: + resolution: {integrity: sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.21.5 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + dev: true + + /@esbuild/android-arm64@0.17.18: + resolution: {integrity: sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.17.18: + resolution: {integrity: sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.17.18: + resolution: {integrity: sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.17.18: + resolution: {integrity: sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.17.18: + resolution: {integrity: sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.17.18: + resolution: {integrity: sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.17.18: + resolution: {integrity: sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.17.18: + resolution: {integrity: sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.17.18: + resolution: {integrity: sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.17.18: + resolution: {integrity: sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.17.18: + resolution: {integrity: sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.17.18: + resolution: {integrity: sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.17.18: + resolution: {integrity: sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.17.18: + resolution: {integrity: sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.17.18: + resolution: {integrity: sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.17.18: + resolution: {integrity: sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.17.18: + resolution: {integrity: sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.17.18: + resolution: {integrity: sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.17.18: + resolution: {integrity: sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.17.18: + resolution: {integrity: sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.17.18: + resolution: {integrity: sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.17.18: + resolution: {integrity: sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.40.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.40.0 + eslint-visitor-keys: 3.4.1 + dev: true + + /@eslint-community/regexpp@4.5.1: + resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.0.3: + resolution: {integrity: sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.5.2 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.40.0: + resolution: {integrity: sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@humanwhocodes/config-array@0.11.8: + resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@microsoft/api-extractor-model@7.26.8: + resolution: {integrity: sha512-ESj3bBJkiMg/8tS0PW4+2rUgTVwOEfy41idTnFgdbVX+O50bN6S99MV6FIPlCZWCnRDcBfwxRXLdAkOQQ0JqGw==} + dependencies: + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 3.58.0 + transitivePeerDependencies: + - '@types/node' + dev: true + + /@microsoft/api-extractor@7.34.8: + resolution: {integrity: sha512-2Eh1PlZ8wULtH3kyAWcj62gFtjGKRXrEplsCO54vMLjiav3qet454VpSBXwKkXBenBylZRMk3SMBcpcuJ8RnKQ==} + hasBin: true + dependencies: + '@microsoft/api-extractor-model': 7.26.8 + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 3.58.0 + '@rushstack/rig-package': 0.3.18 + '@rushstack/ts-command-line': 4.13.2 + colors: 1.2.5 + lodash: 4.17.21 + resolve: 1.22.2 + semver: 7.3.8 + source-map: 0.6.1 + typescript: 4.8.4 + transitivePeerDependencies: + - '@types/node' + dev: true + + /@microsoft/tsdoc-config@0.16.2: + resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} + dependencies: + '@microsoft/tsdoc': 0.14.2 + ajv: 6.12.6 + jju: 1.4.0 + resolve: 1.19.0 + dev: true + + /@microsoft/tsdoc@0.14.2: + resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} + dev: true + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + dev: true + + /@rollup/pluginutils@5.0.2: + resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.1 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@rushstack/node-core-library@3.58.0: + resolution: {integrity: sha512-DHAZ3LTOEq2/EGURznpTJDnB3SNE2CKMDXuviQ6afhru6RykE3QoqXkeyjbpLb5ib5cpIRCPE/wykNe0xmQj3w==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + colors: 1.2.5 + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.2 + semver: 7.3.8 + z-schema: 5.0.5 + dev: true + + /@rushstack/rig-package@0.3.18: + resolution: {integrity: sha512-SGEwNTwNq9bI3pkdd01yCaH+gAsHqs0uxfGvtw9b0LJXH52qooWXnrFTRRLG1aL9pf+M2CARdrA9HLHJys3jiQ==} + dependencies: + resolve: 1.22.2 + strip-json-comments: 3.1.1 + dev: true + + /@rushstack/ts-command-line@4.13.2: + resolution: {integrity: sha512-bCU8qoL9HyWiciltfzg7GqdfODUeda/JpI0602kbN5YH22rzTxyqYvv7aRLENCM7XCQ1VRs7nMkEqgJUOU8Sag==} + dependencies: + '@types/argparse': 1.0.38 + argparse: 1.0.10 + colors: 1.2.5 + string-argv: 0.3.2 + dev: true + + /@ts-morph/common@0.19.0: + resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==} + dependencies: + fast-glob: 3.2.12 + minimatch: 7.4.6 + mkdirp: 2.1.6 + path-browserify: 1.0.1 + dev: true + + /@types/argparse@1.0.38: + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + dev: true + + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + + /@types/json-schema@7.0.11: + resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + dev: true + + /@types/semver@7.3.13: + resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} + dev: true + + /@typescript-eslint/eslint-plugin@5.59.2(@typescript-eslint/parser@5.59.2)(eslint@8.40.0)(typescript@5.0.2): + resolution: {integrity: sha512-yVrXupeHjRxLDcPKL10sGQ/QlVrA8J5IYOEWVqk0lJaSZP7X5DfnP7Ns3cc74/blmbipQ1htFNVGsHX6wsYm0A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.5.1 + '@typescript-eslint/parser': 5.59.2(eslint@8.40.0)(typescript@5.0.2) + '@typescript-eslint/scope-manager': 5.59.2 + '@typescript-eslint/type-utils': 5.59.2(eslint@8.40.0)(typescript@5.0.2) + '@typescript-eslint/utils': 5.59.2(eslint@8.40.0)(typescript@5.0.2) + debug: 4.3.4 + eslint: 8.40.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + semver: 7.3.8 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@5.59.2(eslint@8.40.0)(typescript@5.0.2): + resolution: {integrity: sha512-uq0sKyw6ao1iFOZZGk9F8Nro/8+gfB5ezl1cA06SrqbgJAt0SRoFhb9pXaHvkrxUpZaoLxt8KlovHNk8Gp6/HQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.59.2 + '@typescript-eslint/types': 5.59.2 + '@typescript-eslint/typescript-estree': 5.59.2(typescript@5.0.2) + debug: 4.3.4 + eslint: 8.40.0 + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.59.2: + resolution: {integrity: sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.59.2 + '@typescript-eslint/visitor-keys': 5.59.2 + dev: true + + /@typescript-eslint/type-utils@5.59.2(eslint@8.40.0)(typescript@5.0.2): + resolution: {integrity: sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.59.2(typescript@5.0.2) + '@typescript-eslint/utils': 5.59.2(eslint@8.40.0)(typescript@5.0.2) + debug: 4.3.4 + eslint: 8.40.0 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.59.2: + resolution: {integrity: sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.59.2(typescript@5.0.2): + resolution: {integrity: sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.59.2 + '@typescript-eslint/visitor-keys': 5.59.2 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.3.8 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.59.2(eslint@8.40.0)(typescript@5.0.2): + resolution: {integrity: sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.40.0) + '@types/json-schema': 7.0.11 + '@types/semver': 7.3.13 + '@typescript-eslint/scope-manager': 5.59.2 + '@typescript-eslint/types': 5.59.2 + '@typescript-eslint/typescript-estree': 5.59.2(typescript@5.0.2) + eslint: 8.40.0 + eslint-scope: 5.1.1 + semver: 7.3.8 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.59.2: + resolution: {integrity: sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.59.2 + eslint-visitor-keys: 3.4.1 + dev: true + + /acorn-jsx@5.3.2(acorn@8.8.2): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.8.2 + dev: true + + /acorn@8.8.2: + resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /astring@1.8.4: + resolution: {integrity: sha512-97a+l2LBU3Op3bBQEff79i/E4jMD2ZLFD8rHx9B6mXyB2uQwhJQYfiDqUwtfjF4QA1F2qs//N6Cw8LetMbQjcw==} + hasBin: true + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /code-block-writer@12.0.0: + resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /colors@1.2.5: + resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==} + engines: {node: '>=0.1.90'} + dev: true + + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + requiresBuild: true + dev: true + optional: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /esbuild@0.17.18: + resolution: {integrity: sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.18 + '@esbuild/android-arm64': 0.17.18 + '@esbuild/android-x64': 0.17.18 + '@esbuild/darwin-arm64': 0.17.18 + '@esbuild/darwin-x64': 0.17.18 + '@esbuild/freebsd-arm64': 0.17.18 + '@esbuild/freebsd-x64': 0.17.18 + '@esbuild/linux-arm': 0.17.18 + '@esbuild/linux-arm64': 0.17.18 + '@esbuild/linux-ia32': 0.17.18 + '@esbuild/linux-loong64': 0.17.18 + '@esbuild/linux-mips64el': 0.17.18 + '@esbuild/linux-ppc64': 0.17.18 + '@esbuild/linux-riscv64': 0.17.18 + '@esbuild/linux-s390x': 0.17.18 + '@esbuild/linux-x64': 0.17.18 + '@esbuild/netbsd-x64': 0.17.18 + '@esbuild/openbsd-x64': 0.17.18 + '@esbuild/sunos-x64': 0.17.18 + '@esbuild/win32-arm64': 0.17.18 + '@esbuild/win32-ia32': 0.17.18 + '@esbuild/win32-x64': 0.17.18 + dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /eslint-config-prettier@8.8.0(eslint@8.40.0): + resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.40.0 + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.0: + resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.1: + resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.40.0: + resolution: {integrity: sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.40.0) + '@eslint-community/regexpp': 4.5.1 + '@eslint/eslintrc': 2.0.3 + '@eslint/js': 8.40.0 + '@humanwhocodes/config-array': 0.11.8 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.0 + eslint-visitor-keys: 3.4.1 + espree: 9.5.2 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.20.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-sdsl: 4.4.0 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.1 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@9.5.2: + resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.8.2 + acorn-jsx: 5.3.2(acorn@8.8.2) + eslint-visitor-keys: 3.4.1 + dev: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-glob@3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + dependencies: + reusify: 1.0.4 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flatted@3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globals@13.20.0: + resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.12 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-core-module@2.12.0: + resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + dev: true + + /js-sdsl@4.4.0: + resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: true + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /magic-string@0.29.0: + resolution: {integrity: sha512-WcfidHrDjMY+eLjlU+8OvwREqHwpgCeKVBUpQ3OhYYuvfaYCUgcbuBzappNzZvg/v8onU3oQj+BYpkOJe9Iw4Q==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@7.4.6: + resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /optionator@0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.3 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss@8.4.23: + resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve@1.19.0: + resolution: {integrity: sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==} + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + dev: true + + /resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf@3.0.0: + resolution: {integrity: sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup@3.21.5: + resolution: {integrity: sha512-a4NTKS4u9PusbUJcfF4IMxuqjFzjm6ifj76P54a7cKnvVzJaG12BLVR+hgU2YDGHzyMMQNxLAZWuALsn8q2oQg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /semver@7.3.8: + resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /ts-morph@18.0.0: + resolution: {integrity: sha512-Kg5u0mk19PIIe4islUI/HWRvm9bC1lHejK4S0oh1zaZ77TMZAEmQC0sHQYiu2RgCQFZKXz1fMVi/7nOOeirznA==} + dependencies: + '@ts-morph/common': 0.19.0 + code-block-writer: 12.0.0 + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tsutils@3.21.0(typescript@5.0.2): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 5.0.2 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /typescript@4.8.4: + resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /typescript@5.0.2: + resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} + engines: {node: '>=12.20'} + hasBin: true + dev: true + + /universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /validator@13.9.0: + resolution: {integrity: sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==} + engines: {node: '>= 0.10'} + dev: true + + /vite-plugin-dts@2.3.0(vite@4.3.2): + resolution: {integrity: sha512-WbJgGtsStgQhdm3EosYmIdTGbag5YQpZ3HXWUAPCDyoXI5qN6EY0V7NXq0lAmnv9hVQsvh0htbYcg0Or5Db9JQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: '>=2.9.0' + dependencies: + '@babel/parser': 7.21.8 + '@microsoft/api-extractor': 7.34.8 + '@rollup/pluginutils': 5.0.2 + '@rushstack/node-core-library': 3.58.0 + debug: 4.3.4 + fast-glob: 3.2.12 + fs-extra: 10.1.0 + kolorist: 1.8.0 + magic-string: 0.29.0 + ts-morph: 18.0.0 + vite: 4.3.2 + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + dev: true + + /vite@4.3.2: + resolution: {integrity: sha512-9R53Mf+TBoXCYejcL+qFbZde+eZveQLDYd9XgULILLC1a5ZwPaqgmdVpL8/uvw2BM/1TzetWjglwm+3RO+xTyw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.17.18 + postcss: 8.4.23 + rollup: 3.21.5 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /word-wrap@1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.9.0 + optionalDependencies: + commander: 9.5.0 + dev: true diff --git a/frontend/player/src/hls/mod.rs b/frontend/player/src/hls/mod.rs new file mode 100644 index 00000000..e4d45353 --- /dev/null +++ b/frontend/player/src/hls/mod.rs @@ -0,0 +1,3 @@ +mod playlist; + +pub use playlist::*; diff --git a/frontend/player/src/hls/playlist/master.rs b/frontend/player/src/hls/playlist/master.rs new file mode 100644 index 00000000..a31ea098 --- /dev/null +++ b/frontend/player/src/hls/playlist/master.rs @@ -0,0 +1,142 @@ +use std::collections::HashMap; + +use serde::Serialize; + +use super::utils::Tag; + +#[derive(Debug, Clone, Serialize)] +pub struct MasterPlaylist { + pub streams: Vec, + pub groups: HashMap>, +} + +#[derive(Debug, Clone, Serialize)] +pub enum MediaType { + Audio, + Video, + Subtitles, + ClosedCaptions, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Media { + pub media_type: MediaType, + pub uri: String, + pub name: String, + pub autoselect: bool, + pub default: bool, + pub forced: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Stream { + pub uri: String, + pub bandwidth: u32, + pub average_bandwidth: Option, + pub codecs: Option, + pub resolution: Option<(u32, u32)>, + pub frame_rate: Option, + pub hdcp_level: Option, + pub audio: Option, + pub video: Option, + pub subtitles: Option, + pub closed_captions: Option, +} + +impl MasterPlaylist { + pub fn from_tags(tags: Vec) -> Result { + let streams = tags + .iter() + .filter_map(|t| match t { + Tag::ExtXStreamInf(attributes, uri) => Some(Stream { + uri: uri.clone(), + audio: attributes.get("AUDIO").map(|s| s.to_string()), + video: attributes.get("VIDEO").map(|s| s.to_string()), + subtitles: attributes.get("SUBTITLES").map(|s| s.to_string()), + closed_captions: attributes.get("CLOSED-CAPTIONS").map(|s| s.to_string()), + bandwidth: attributes + .get("BANDWIDTH") + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + average_bandwidth: attributes + .get("AVERAGE-BANDWIDTH") + .and_then(|s| s.parse().ok()), + codecs: attributes.get("CODECS").map(|s| s.to_string()), + resolution: attributes.get("RESOLUTION").and_then(|s| { + let mut parts = s.split('x'); + let width = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let height = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + if width == 0 || height == 0 { + None + } else { + Some((width, height)) + } + }), + frame_rate: attributes.get("FRAME-RATE").and_then(|s| s.parse().ok()), + hdcp_level: attributes.get("HDCP-LEVEL").map(|s| s.to_string()), + }), + _ => None, + }) + .collect(); + + let groups = tags + .iter() + .filter_map(|t| match t { + Tag::ExtXMedia(attributes) => { + let media_type = match attributes.get("TYPE").map(|s| s.as_str()) { + Some("AUDIO") => MediaType::Audio, + Some("VIDEO") => MediaType::Video, + Some("SUBTITLES") => MediaType::Subtitles, + Some("CLOSED-CAPTIONS") => MediaType::ClosedCaptions, + _ => return None, + }; + + let uri = attributes + .get("URI") + .map(|s| s.to_string()) + .unwrap_or_default(); + let name = attributes + .get("NAME") + .map(|s| s.to_string()) + .unwrap_or_default(); + let autoselect = attributes + .get("AUTOSELECT") + .map(|s| s == "YES") + .unwrap_or_default(); + let default = attributes + .get("DEFAULT") + .map(|s| s == "YES") + .unwrap_or_default(); + let forced = attributes + .get("FORCED") + .map(|s| s == "YES") + .unwrap_or_default(); + + Some(( + attributes + .get("GROUP-ID") + .map(|s| s.to_string()) + .unwrap_or_default(), + Media { + media_type, + uri, + name, + autoselect, + default, + forced, + }, + )) + } + _ => None, + }) + .fold( + HashMap::>::new(), + |mut groups, (group_id, media)| { + groups.entry(group_id).or_default().push(media); + groups + }, + ); + + Ok(Self { streams, groups }) + } +} diff --git a/frontend/player/src/hls/playlist/media.rs b/frontend/player/src/hls/playlist/media.rs new file mode 100644 index 00000000..823ad9c6 --- /dev/null +++ b/frontend/player/src/hls/playlist/media.rs @@ -0,0 +1,233 @@ +use serde::Serialize; + +use super::utils::Tag; + +#[derive(Debug, Clone, Serialize)] +pub struct MediaPlaylist { + pub version: u8, + pub target_duration: u32, + pub media_sequence: u32, + pub discontinuity_sequence: u32, + pub end_list: bool, + pub server_control: Option, + pub segments: Vec, + pub preload_hint: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PreloadHint { + pub hint_type: String, + pub uri: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServerControl { + pub can_skip_until: f64, + pub hold_back: f64, + pub part_hold_back: f64, + pub can_block_reload: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Segment { + pub discontinuity: bool, + pub map: Option, + pub sn: u32, + pub duration: f64, + pub url: String, + pub program_date_time: Option, + pub gap: bool, + pub parts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Part { + pub duration: f64, + pub uri: String, + pub independent: bool, +} + +impl MediaPlaylist { + pub fn from_tags(tags: Vec) -> Result { + let version = tags + .iter() + .find_map(|t| match t { + Tag::ExtXVersion(v) => Some(*v), + _ => None, + }) + .ok_or("no #EXT-X-VERSION tag found")?; + + let target_duration = tags + .iter() + .find_map(|t| match t { + Tag::ExtXTargetDuration(d) => Some(*d), + _ => None, + }) + .ok_or("no #EXT-X-TARGETDURATION tag found")?; + + let media_sequence = tags + .iter() + .find_map(|t| match t { + Tag::ExtXMediaSequence(s) => Some(*s), + _ => None, + }) + .unwrap_or_default(); + + let discontinuity_sequence = tags + .iter() + .find_map(|t| match t { + Tag::ExtXDiscontinuitySequence(s) => Some(*s), + _ => None, + }) + .unwrap_or_default(); + + let end_list = tags.iter().any(|t| t == &Tag::ExtXEndList); + + let server_control = tags.iter().find_map(|t| match t { + Tag::ExtXServerControl(attributes) => { + let can_skip_until = attributes + .get("CAN-SKIP-UNTIL") + .and_then(|v| v.parse().ok()) + .unwrap_or_default(); + let hold_back = attributes + .get("HOLD-BACK") + .and_then(|v| v.parse().ok()) + .unwrap_or_default(); + let part_hold_back = attributes + .get("PART-HOLD-BACK") + .and_then(|v| v.parse().ok()) + .unwrap_or_default(); + let can_block_reload = attributes + .get("CAN-BLOCK-RELOAD") + .and_then(|v| match v.as_str() { + "YES" => Some(true), + "NO" => Some(false), + _ => v.parse().ok(), + }) + .unwrap_or_default(); + + Some(ServerControl { + can_skip_until, + hold_back, + part_hold_back, + can_block_reload, + }) + } + _ => None, + }); + + let mut map = None; + let mut sn = media_sequence; + let mut segments = Vec::new(); + let mut current_segment = None; + let mut program_date_time = None; + let mut discontinuity = false; + + for tag in tags.iter() { + match tag { + Tag::ExtXProgramDateTime(d) => { + program_date_time = Some(d); + } + Tag::ExtXMap(attributes) => { + let uri = attributes.get("URI").ok_or("no URI attribute found")?; + map = Some(uri.clone()); + } + Tag::ExtInf(duration, url) => { + let mut current_segment = current_segment.take().unwrap_or_else(|| Segment { + discontinuity, + map: map.take(), + sn, + duration: 0.0, + url: "".to_string(), + program_date_time: program_date_time.cloned(), + gap: false, + parts: Vec::new(), + }); + + current_segment.duration = *duration; + current_segment.url = url.clone(); + + discontinuity = false; + sn += 1; + + segments.push(current_segment); + } + Tag::ExtXDiscontinuity => { + discontinuity = true; + } + Tag::ExtXPart(attributes) => { + let duration = attributes + .get("DURATION") + .ok_or("no DURATION attribute found")?; + let duration = duration + .parse() + .map_err(|_| "DURATION attribute is not a number")?; + let uri = attributes.get("URI").ok_or("no URI attribute found")?; + let independent = attributes + .get("INDEPENDENT") + .and_then(|v| match v.as_str() { + "YES" => Some(true), + "NO" => Some(false), + _ => v.parse().ok(), + }) + .unwrap_or_default(); + + let part = Part { + duration, + uri: uri.clone(), + independent, + }; + + let current_segment = current_segment.get_or_insert_with(|| Segment { + discontinuity, + map: map.clone(), + sn, + duration: 0.0, + url: "".to_string(), + program_date_time: program_date_time.cloned(), + gap: false, + parts: Vec::new(), + }); + + current_segment.parts.push(part); + } + _ => {} + } + } + + if let Some(segment) = current_segment { + segments.push(segment); + } + + let preload_hint = tags + .iter() + .filter_map(|t| match t { + Tag::ExtXPreloadHint(hint) => { + let Some(hint_type) = hint.get("TYPE") else { + return Some(Err("no TYPE attribute found")); + }; + let Some(uri) = hint.get("URI") else { + return Some(Err("no URI attribute found")); + }; + + Some(Ok(PreloadHint { + hint_type: hint_type.clone(), + uri: uri.clone(), + })) + } + _ => None, + }) + .collect::, _>>()?; + + Ok(Self { + version, + target_duration, + media_sequence, + discontinuity_sequence, + end_list, + server_control, + segments, + preload_hint, + }) + } +} diff --git a/frontend/player/src/hls/playlist/mod.rs b/frontend/player/src/hls/playlist/mod.rs new file mode 100644 index 00000000..bcf5ef9a --- /dev/null +++ b/frontend/player/src/hls/playlist/mod.rs @@ -0,0 +1,45 @@ +pub mod master; +pub mod media; + +mod utils; + +use std::str::FromStr; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub enum Playlist { + Master(master::MasterPlaylist), + Media(media::MediaPlaylist), +} + +impl FromStr for Playlist { + type Err = String; + + fn from_str(s: &str) -> Result { + let tags = utils::parse_tags(s)?; + + if tags.is_empty() { + return Err("no tags found".to_string()); + } + + if tags.first().unwrap() != &utils::Tag::ExtM3u { + return Err("first tag is not #EXTM3U".to_string()); + } + + if tags.iter().all(|t| t.is_master_tag()) { + Ok(Playlist::Master(master::MasterPlaylist::from_tags(tags)?)) + } else { + Ok(Playlist::Media(media::MediaPlaylist::from_tags(tags)?)) + } + } +} + +impl TryFrom<&[u8]> for Playlist { + type Error = String; + + fn try_from(value: &[u8]) -> Result { + let s = std::str::from_utf8(value).map_err(|_| "invalid bytes found in stream")?; + s.parse() + } +} diff --git a/frontend/player/src/hls/playlist/utils.rs b/frontend/player/src/hls/playlist/utils.rs new file mode 100644 index 00000000..65a556b6 --- /dev/null +++ b/frontend/player/src/hls/playlist/utils.rs @@ -0,0 +1,403 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Tag { + ExtM3u, + ExtXVersion(u8), + ExtXIndependentSegments, + ExtXStart(HashMap), + ExtXDefine(HashMap), + ExtXTargetDuration(u32), + ExtXMediaSequence(u32), + ExtXDiscontinuitySequence(u32), + ExtXEndList, + ExtXPlaylistType(PlaylistType), + ExtXIFramesOnly, + ExtXPartInf(HashMap), + ExtXServerControl(HashMap), + ExtInf(f64, String), + ExtXByteRange(u32, Option), + ExtXDiscontinuity, + ExtXKey(HashMap), + ExtXMap(HashMap), + ExtXProgramDateTime(String), + ExtXGap, + ExtXBitrate(u32), + ExtXPart(HashMap), + ExtXDateRange(HashMap), + ExtXSkip(HashMap), + ExtXPreloadHint(HashMap), + ExtXRenditionReport(HashMap), + ExtXMedia(HashMap), + ExtXStreamInf(HashMap, String), + ExtXIFrameStreamInf(HashMap), + ExtXSessionData(HashMap), + ExtXSessionKey(HashMap), + ExtXContentSteering(HashMap), + Unknown(String), +} + +impl Tag { + pub fn is_master_tag(&self) -> bool { + matches!( + self, + Tag::ExtM3u + | Tag::ExtXVersion(_) + | Tag::ExtXIndependentSegments + | Tag::ExtXStart(_) + | Tag::ExtXDefine(_) + | Tag::ExtXMedia(_) + | Tag::ExtXStreamInf(_, _) + | Tag::ExtXIFrameStreamInf(_) + | Tag::ExtXSessionData(_) + | Tag::ExtXSessionKey(_) + | Tag::ExtXContentSteering(_) + | Tag::Unknown(_) + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaylistType { + Event, + Vod, +} + +pub fn parse_tags(input: &str) -> Result, String> { + // We first need to lazily parse the input into lines + let mut lines = input.lines().filter(|l| !l.is_empty()); + + let mut tags = Vec::new(); + while let Some(tag) = parse_tag(&mut lines)? { + tags.push(tag); + } + + Ok(tags) +} + +fn parse_attributes(line: &str) -> Result, String> { + let mut attributes = HashMap::new(); + + let mut key = None; + let mut value = String::new(); + + let mut chars = line.chars(); + while let Some(c) = chars.next() { + match c { + '=' => { + key = Some(value); + value = String::new(); + } + ',' => { + let Some(key) = key.take() else { + continue + }; + + attributes.insert(key, value); + value = String::new(); + } + '"' => { + let mut value = String::new(); + + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => { + let c = chars.next().ok_or("invalid attribute2")?; + + match c { + '"' => value.push('"'), + '\\' => value.push('\\'), + 'n' => value.push('\n'), + 'r' => value.push('\r'), + 't' => value.push('\t'), + _ => return Err("invalid attribute3".into()), + } + } + _ => value.push(c), + } + } + + let key = key.take().ok_or("invalid attribute4")?; + attributes.insert(key, value); + } + c => { + value.push(c); + } + } + } + + if let Some(key) = key.take() { + attributes.insert(key, value); + } + + Ok(attributes) +} + +fn parse_tag<'a>(lines: &mut impl Iterator) -> Result, String> { + let line = match lines.next() { + Some(line) => line, + None => return Ok(None), + }; + + match line { + "#EXTM3U" => Ok(Some(Tag::ExtM3u)), + line if line.starts_with("#EXT-X-VERSION:") => { + let version = line + .strip_prefix("#EXT-X-VERSION:") + .ok_or("invalid version")? + .parse() + .map_err(|_| "invalid version")?; + + Ok(Some(Tag::ExtXVersion(version))) + } + _ if line.starts_with("#EXT-X-INDEPENDENT-SEGMENTS") => { + Ok(Some(Tag::ExtXIndependentSegments)) + } + line if line.starts_with("#EXT-X-START:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-START:").ok_or("invalid start")?)?; + + Ok(Some(Tag::ExtXStart(attributes))) + } + line if line.starts_with("#EXT-X-DEFINE:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-DEFINE:") + .ok_or("invalid define")?, + )?; + + Ok(Some(Tag::ExtXDefine(attributes))) + } + line if line.starts_with("#EXT-X-TARGETDURATION:") => { + let duration = line + .strip_prefix("#EXT-X-TARGETDURATION:") + .ok_or("invalid target duration")? + .parse() + .map_err(|_| "invalid target duration")?; + + Ok(Some(Tag::ExtXTargetDuration(duration))) + } + line if line.starts_with("#EXT-X-MEDIA-SEQUENCE:") => { + let sequence = line + .strip_prefix("#EXT-X-MEDIA-SEQUENCE:") + .ok_or("invalid media sequence")? + .parse() + .map_err(|_| "invalid media sequence")?; + + Ok(Some(Tag::ExtXMediaSequence(sequence))) + } + line if line.starts_with("#EXT-X-DISCONTINUITY-SEQUENCE:") => { + let sequence = line + .strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") + .ok_or("invalid discontinuity sequence")? + .parse() + .map_err(|_| "invalid discontinuity sequence")?; + + Ok(Some(Tag::ExtXDiscontinuitySequence(sequence))) + } + _ if line.starts_with("#EXT-X-ENDLIST") => Ok(Some(Tag::ExtXEndList)), + line if line.starts_with("#EXT-X-PLAYLIST-TYPE:") => { + let playlist_type = match line + .strip_prefix("#EXT-X-PLAYLIST-TYPE:") + .ok_or("invalid playlist type")? + .to_uppercase() + .as_str() + { + "EVENT" => PlaylistType::Event, + "VOD" => PlaylistType::Vod, + _ => return Err("invalid playlist type".to_string()), + }; + + Ok(Some(Tag::ExtXPlaylistType(playlist_type))) + } + _ if line.starts_with("#EXT-X-I-FRAMES-ONLY") => Ok(Some(Tag::ExtXIFramesOnly)), + line if line.starts_with("#EXT-X-PART-INF:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-PART-INF:") + .ok_or("invalid part inf")?, + )?; + + Ok(Some(Tag::ExtXPartInf(attributes))) + } + line if line.starts_with("#EXT-X-SERVER-CONTROL:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-SERVER-CONTROL:") + .ok_or("invalid server control")?, + )?; + + Ok(Some(Tag::ExtXServerControl(attributes))) + } + line if line.starts_with("#EXT-X-SESSION-KEY:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-SESSION-KEY:") + .ok_or("invalid session key")?, + )?; + + Ok(Some(Tag::ExtXSessionKey(attributes))) + } + line if line.starts_with("#EXT-X-SESSION-DATA:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-SESSION-DATA:") + .ok_or("invalid session data")?, + )?; + + Ok(Some(Tag::ExtXSessionData(attributes))) + } + line if line.starts_with("#EXTINF:") => { + let mut splits = line + .strip_prefix("#EXTINF:") + .ok_or("invalid duration")? + .split(','); + + let duration = splits + .next() + .ok_or("invalid duration")? + .parse() + .map_err(|_| "invalid duration")?; + + Ok(Some(Tag::ExtInf( + duration, + lines.next().ok_or("invalid uri")?.into(), + ))) + } + line if line.starts_with("#EXT-X-BYTERANGE:") => { + let mut splits = line + .strip_prefix("#EXT-X-BYTERANGE:") + .ok_or("invalid byterange")? + .split('@'); + + let length = splits + .next() + .ok_or("invalid byterange")? + .parse() + .map_err(|_| "invalid byterange")?; + + let offset = match splits + .next() + .map(|s| s.parse().map_err(|_| "invalid byterange")) + { + Some(Ok(offset)) => Some(offset), + Some(Err(err)) => return Err(err.into()), + None => None, + }; + + Ok(Some(Tag::ExtXByteRange(length, offset))) + } + _ if line.starts_with("#EXT-X-DISCONTINUITY") => Ok(Some(Tag::ExtXDiscontinuity)), + line if line.starts_with("#EXT-X-KEY:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-KEY:").ok_or("invalid key")?)?; + + Ok(Some(Tag::ExtXKey(attributes))) + } + line if line.starts_with("#EXT-X-MAP:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-MAP:").ok_or("invalid map")?)?; + + Ok(Some(Tag::ExtXMap(attributes))) + } + line if line.starts_with("#EXT-X-PROGRAM-DATE-TIME:") => { + let date_time = line + .strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") + .ok_or("invalid program date time")?; + + Ok(Some(Tag::ExtXProgramDateTime(date_time.into()))) + } + _ if line.starts_with("#EXT-X-GAP") => Ok(Some(Tag::ExtXGap)), + line if line.starts_with("#EXT-X-BITRATE:") => { + let bitrate = line + .strip_prefix("#EXT-X-BITRATE:") + .ok_or("invalid bitrate")? + .parse() + .map_err(|_| "invalid bitrate")?; + + Ok(Some(Tag::ExtXBitrate(bitrate))) + } + line if line.starts_with("#EXT-X-PART:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-PART:").ok_or("invalid part")?)?; + + Ok(Some(Tag::ExtXPart(attributes))) + } + line if line.starts_with("#EXT-X-PRELOAD-HINT:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-PRELOAD-HINT:") + .ok_or("invalid preload hint")?, + )?; + + Ok(Some(Tag::ExtXPreloadHint(attributes))) + } + line if line.starts_with("#EXT-X-DATERANGE:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-DATERANGE:") + .ok_or("invalid datarange")?, + )?; + + Ok(Some(Tag::ExtXDateRange(attributes))) + } + line if line.starts_with("#EXT-X-SKIP:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-SKIP:").ok_or("invalid skip")?)?; + + Ok(Some(Tag::ExtXSkip(attributes))) + } + line if line.starts_with("#EXT-X-RENDITION-REPORT:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-RENDITION-REPORT:") + .ok_or("invalid rendition report")?, + )?; + + Ok(Some(Tag::ExtXRenditionReport(attributes))) + } + line if line.starts_with("#EXT-X-MEDIA:") => { + let attributes = + parse_attributes(line.strip_prefix("#EXT-X-MEDIA:").ok_or("invalid media")?)?; + + Ok(Some(Tag::ExtXMedia(attributes))) + } + line if line.starts_with("#EXT-X-STREAM-INF:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-STREAM-INF:") + .ok_or("invalid stream inf")?, + )?; + + Ok(Some(Tag::ExtXStreamInf( + attributes, + lines.next().ok_or("invalid stream inf")?.into(), + ))) + } + line if line.starts_with("#EXT-X-I-FRAME-STREAM-INF:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") + .ok_or("invalid iframe stream inf")?, + )?; + + Ok(Some(Tag::ExtXIFrameStreamInf(attributes))) + } + line if line.starts_with("#EXT-X-SESSION-DATA:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-SESSION-DATA:") + .ok_or("invalid session data")?, + )?; + + Ok(Some(Tag::ExtXSessionData(attributes))) + } + line if line.starts_with("#EXT-X-SESSION-KEY:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-SESSION-KEY:") + .ok_or("invalid session key")?, + )?; + + Ok(Some(Tag::ExtXSessionKey(attributes))) + } + line if line.starts_with("#EXT-X-CONTENT-STEERING:") => { + let attributes = parse_attributes( + line.strip_prefix("#EXT-X-CONTENT-STEERING:") + .ok_or("invalid content steering")?, + )?; + + Ok(Some(Tag::ExtXContentSteering(attributes))) + } + line => Ok(Some(Tag::Unknown(line.into()))), + } +} diff --git a/frontend/player/src/lib.rs b/frontend/player/src/lib.rs index 04f68c70..abe90214 100644 --- a/frontend/player/src/lib.rs +++ b/frontend/player/src/lib.rs @@ -1,22 +1,14 @@ -mod utils; - use wasm_bindgen::prelude::*; -#[wasm_bindgen] -extern "C" { - fn alert(s: &str); -} +mod hls; +mod player; +mod tracing_wasm; -#[wasm_bindgen(start)] +#[wasm_bindgen(start, skip_typescript)] pub fn main() -> Result<(), JsValue> { - utils::set_panic_hook(); + console_error_panic_hook::set_once(); - utils::set_logging(); + tracing_wasm::set_as_global_default(); Ok(()) } - -#[wasm_bindgen] -pub fn add(a: u32, b: u32) -> u32 { - a + b -} diff --git a/frontend/player/src/player/blank.rs b/frontend/player/src/player/blank.rs new file mode 100644 index 00000000..bfd72d37 --- /dev/null +++ b/frontend/player/src/player/blank.rs @@ -0,0 +1,141 @@ +use bytes::Bytes; +use h264::{AVCDecoderConfigurationRecord, AvccExtendedConfig}; +use mp4::{ + types::{ + avc1::Avc1, + avcc::AvcC, + hdlr::{HandlerType, Hdlr}, + mdat::Mdat, + mdhd::Mdhd, + mdia::Mdia, + mfhd::Mfhd, + minf::Minf, + moof::Moof, + moov::Moov, + mvex::Mvex, + mvhd::Mvhd, + stbl::Stbl, + stco::Stco, + stsc::Stsc, + stsd::{SampleEntry, Stsd, VisualSampleEntry}, + stts::Stts, + tfdt::Tfdt, + tfhd::Tfhd, + tkhd::Tkhd, + traf::Traf, + trak::Trak, + trex::Trex, + trun::{Trun, TrunSample, TrunSampleFlag}, + vmhd::Vmhd, + }, + BoxType, DynBox, +}; + +pub struct VideoFactory { + timescale: u32, + sequence_number: u32, +} + +impl VideoFactory { + pub fn new(timescale: u32) -> Self { + Self { + sequence_number: 0, + timescale, + } + } + + pub fn moov(&self) -> Moov { + Moov::new( + Mvhd::new(0, 0, 1000, 0, 2), + vec![Trak::new( + Tkhd::new( + 0, + 0, + 1, + 0, + Some((2, 2)), + ), + None, + Mdia::new( + Mdhd::new(0, 0, self.timescale, 0), + Hdlr::new(HandlerType::Vide, "".to_string()), + Minf::new( + Stbl::new( + Stsd::new(vec![DynBox::Avc1(Avc1::new( + SampleEntry::new(VisualSampleEntry::new(2, 2, None)), + AvcC::new(AVCDecoderConfigurationRecord { + configuration_version: 1, + profile_indication: 100, + profile_compatibility: 0, + level_indication: 10, + length_size_minus_one: 3, + sps: vec![Bytes::from_static(b"gd\0\n\xac\xd9_\x88\x88\xc0D\0\0\x03\0\x04\0\0\x03\0\x08 (Moof, Mdat) { + self.sequence_number += 1; + + let mdat = Mdat::new(vec![Bytes::from_static(b"\0\0\x02\xad\x06\x05\xff\xff\xa9\xdcE\xe9\xbd\xe6\xd9H\xb7\x96,\xd8 \xd9#\xee\xefx264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=1 lookahead_threads=1 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=1 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\0\x80\0\0\0\x10e\x88\x84\0\x15\xff\xfe\xf7\xc9\xef\xc0\xa6\xeb\xdb\xdf\x81")]); + + let mut moof = Moof::new( + Mfhd::new(self.sequence_number), + vec![Traf::new( + Tfhd::new( + 1, + None, + None, + Some(duration), + Some(mdat.primitive_size() as u32), + Some(TrunSampleFlag { + reserved: 0, + is_leading: 0, + sample_depends_on: 2, + sample_is_depended_on: 0, + sample_has_redundancy: 0, + sample_padding_value: 0, + sample_is_non_sync_sample: false, + sample_degradation_priority: 0, + }), + ), + Some(Trun::new( + vec![TrunSample { + composition_time_offset: None, + duration: None, + size: None, + flags: None, + }], + None, + )), + Some(Tfdt::new(decode_time)), + )], + ); + + let offset = moof.size() + 8; + + moof.traf[0].trun.as_mut().unwrap().data_offset = Some(offset as i32); + + (moof, mdat) + } +} diff --git a/frontend/player/src/player/events.rs b/frontend/player/src/player/events.rs new file mode 100644 index 00000000..3917cdf4 --- /dev/null +++ b/frontend/player/src/player/events.rs @@ -0,0 +1,56 @@ +use serde::Serialize; +use tsify::Tsify; +use wasm_bindgen::prelude::*; + +use super::track::Track; + +#[wasm_bindgen(getter_with_clone)] +pub struct EventError { + #[wasm_bindgen(readonly)] + pub error: JsValue, +} + +impl From for EventError { + fn from(error: JsValue) -> Self { + Self { error } + } +} + +#[wasm_bindgen(typescript_custom_section)] +const _: &'static str = r#" +type OnErrorFunction = (this: null, evt: EventError) => void; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "OnErrorFunction")] + pub type OnErrorFunction; + + #[wasm_bindgen(catch, method, js_name = call)] + pub fn call(this: &OnErrorFunction, ctx: JsValue, evt: EventError) -> Result<(), JsValue>; +} + +#[wasm_bindgen(typescript_custom_section)] +const _: &'static str = r#" +type OnManifestLoadedFunction = (this: null, evt: EventManifestLoaded) => void; +"#; + +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +pub struct EventManifestLoaded { + pub is_master_playlist: bool, + pub tracks: Vec, +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "OnManifestLoadedFunction")] + pub type OnManifestLoadedFunction; + + #[wasm_bindgen(catch, method, js_name = call)] + pub fn call( + this: &OnManifestLoadedFunction, + ctx: JsValue, + evt: EventManifestLoaded, + ) -> Result<(), JsValue>; +} diff --git a/frontend/player/src/player/fetch.rs b/frontend/player/src/player/fetch.rs new file mode 100644 index 00000000..2a8b2edc --- /dev/null +++ b/frontend/player/src/player/fetch.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; + +use js_sys::ArrayBuffer; +use tokio::sync::mpsc; +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use web_sys::{XmlHttpRequest, XmlHttpRequestResponseType}; + +pub struct FetchRequest { + url: String, + headers: HashMap, + method: String, + timeout: Option, +} +pub struct InflightRequest { + xhr: XmlHttpRequest, +} + +#[derive(Debug, Default, Clone)] +pub struct Metrics { + pub start_time: f64, + pub ttfb: f64, + pub download_time: f64, + pub download_size: u32, + pub size: u32, + pub cached: bool, +} + +impl FetchRequest { + pub fn new(method: impl ToString, url: impl ToString) -> Self { + Self { + url: url.to_string(), + headers: HashMap::new(), + method: method.to_string(), + timeout: None, + } + } + + pub fn header(mut self, key: impl ToString, value: impl ToString) -> Self { + self.headers.insert(key.to_string(), value.to_string()); + self + } + + pub fn set_timeout(mut self, timeout: u32) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn start(&self) -> Result { + let req = XmlHttpRequest::new()?; + + req.set_response_type(XmlHttpRequestResponseType::Arraybuffer); + + if let Some(timeout) = self.timeout { + req.set_timeout(timeout); + } + + req.open(&self.method, &self.url)?; + + for (key, value) in &self.headers { + req.set_request_header(key, value)?; + } + + req.send()?; + + Ok(InflightRequest { xhr: req }) + } +} + +impl InflightRequest { + pub async fn wait_result(&self) -> Result, JsValue> { + let (tx, mut rx) = mpsc::channel(1); + + let closure = Closure::::new(move || { + tx.try_send(()).ok(); + }); + + self.xhr + .set_onloadend(Some(closure.as_ref().unchecked_ref())); + + rx.recv().await; + + self.xhr.set_onloadend(None); + drop(closure); + + let result = self.result()?; + + result.ok_or(JsValue::from_str("no result from request")) + } + + pub fn is_done(&self) -> bool { + self.xhr.ready_state() == XmlHttpRequest::DONE + } + + pub fn result(&self) -> Result>, JsValue> { + if !self.is_done() { + return Ok(None); + } + + if self.xhr.status()? >= 399 { + return Err(JsValue::from_str( + format!("HTTP Error: {}", self.xhr.status()?).as_str(), + )); + } + + let resp = self.xhr.response()?; + let Some(buf) = resp.dyn_ref::() else { + return Err(resp); + }; + + Ok(Some(js_sys::Uint8Array::new(buf).to_vec())) + } + + pub fn abort(&self) { + self.xhr.abort().ok(); + } +} + +impl Drop for InflightRequest { + fn drop(&mut self) { + self.xhr.set_onloadend(None); + self.abort(); + } +} diff --git a/frontend/player/src/player/inner.rs b/frontend/player/src/player/inner.rs new file mode 100644 index 00000000..bfbd2551 --- /dev/null +++ b/frontend/player/src/player/inner.rs @@ -0,0 +1,213 @@ +use std::{ + cell::{Cell, Ref, RefCell, RefMut}, + panic::Location, + rc::Rc, +}; + +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::HtmlVideoElement; + +use super::{ + events::{EventError, EventManifestLoaded, OnErrorFunction, OnManifestLoadedFunction}, + track::Track, +}; + +pub struct PlayerInner { + url: String, + low_latency: bool, + is_master_playlist: bool, + abr_estimate: Option, + video_element: Option, + on_error: Option, + on_manifest_loaded: Option, + tracks: Vec, + + active_track_id: u32, + active_reference_track_ids: Vec, + + next_track_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NextTrack { + Switch(u32), + Force(u32), +} + +impl NextTrack { + pub fn track_id(&self) -> u32 { + match self { + Self::Switch(id) | Self::Force(id) => *id, + } + } + + pub fn is_force(&self) -> bool { + matches!(self, Self::Force(_)) + } +} + +impl Default for PlayerInner { + fn default() -> Self { + Self { + url: String::new(), + low_latency: true, + is_master_playlist: false, + abr_estimate: None, + on_error: None, + on_manifest_loaded: None, + tracks: Vec::new(), + video_element: None, + active_reference_track_ids: Vec::new(), + active_track_id: 0, + next_track_id: None, + } + } +} + +#[derive(Default, Clone)] +pub struct PlayerInnerHolder { + inner: Rc>, + previous_holder: Cell>>, +} + +impl PlayerInnerHolder { + const AQUIRE_ERROR: &'static str = r#"We failed to borrow the inner state, this is a bug! +Likely caused by holidng a reference to the inner state across an await point. +If you see this error, please file a bug report at https://github.com/scuffletv/scuffle"#; + + #[track_caller] + pub fn aquire(&self) -> Ref<'_, PlayerInner> { + let Ok(inner) = self.inner.try_borrow() else { + tracing::error!("{}\nPrevious hold at: {}\nNew hold at: {}", Self::AQUIRE_ERROR, self.previous_holder.get().unwrap(), Location::caller()); + unreachable!("{}", Self::AQUIRE_ERROR) + }; + + self.previous_holder.set(Some(Location::caller())); + + inner + } + + #[track_caller] + pub fn aquire_mut(&self) -> RefMut<'_, PlayerInner> { + let Ok(inner) = self.inner.try_borrow_mut() else { + tracing::error!("{}\nPrevious hold at: {}\nNew hold at: {}", Self::AQUIRE_ERROR, self.previous_holder.get().unwrap(), Location::caller()); + unreachable!("{}", Self::AQUIRE_ERROR) + }; + + self.previous_holder.set(Some(Location::caller())); + + inner + } +} + +impl PlayerInner { + pub fn url(&self) -> &str { + &self.url + } + + pub fn low_latency(&self) -> bool { + self.low_latency + } + + pub fn video_element(&self) -> Option { + self.video_element.clone() + } + + pub fn set_video_element(&mut self, element: Option) { + self.video_element = element; + } + + pub fn tracks(&self) -> &[Track] { + &self.tracks + } + + pub fn set_url(&mut self, url: impl ToString) { + self.url = url.to_string(); + } + + pub fn set_on_error(&mut self, f: Option) { + self.on_error = f; + } + + pub fn set_on_manifest_loaded(&mut self, f: Option) { + self.on_manifest_loaded = f; + } + + pub fn on_error(&self) -> Option { + self.on_error + .as_ref() + .map(|s| s.dyn_ref::().unwrap().clone().unchecked_into()) + } + + pub fn on_manifest_loaded(&self) -> Option { + self.on_manifest_loaded + .as_ref() + .map(|s| s.dyn_ref::().unwrap().clone().unchecked_into()) + } + + pub fn set_low_latency(&mut self, low_latency: bool) { + self.low_latency = low_latency; + } + + pub fn set_abr_estimate(&mut self, abr_estimate: Option) { + self.abr_estimate = abr_estimate; + } + + pub fn set_active_track_id(&mut self, track_id: u32) { + self.active_track_id = track_id; + } + + pub fn active_track_id(&self) -> u32 { + self.active_track_id + } + + pub fn set_active_reference_track_ids(&mut self, groups: Vec) { + self.active_reference_track_ids = groups; + } + + pub fn set_next_track_id(&mut self, track_id: Option) { + self.next_track_id = track_id; + } + + pub fn next_track_id(&self) -> Option { + self.next_track_id + } + + pub fn set_tracks(&mut self, tracks: Vec, master_playlist: bool) -> impl FnOnce() { + self.tracks = tracks; + self.is_master_playlist = master_playlist; + + self.send_manifest_loaded(EventManifestLoaded { + is_master_playlist: self.is_master_playlist, + tracks: self.tracks.clone(), + }) + } + + pub fn send_error(&self, error: EventError) -> impl FnOnce() { + let js_fn = self.on_error(); + move || { + if let Some(f) = js_fn { + if let Err(err) = f.call(JsValue::null(), error) { + tracing::error!("Error in on_error callback: {:?}", err); + } + } + } + } + + pub fn send_manifest_loaded(&self, evt: EventManifestLoaded) -> impl FnOnce() { + let js_fn = self.on_manifest_loaded(); + move || { + if let Some(f) = js_fn { + if let Err(err) = f + .dyn_ref::() + .unwrap() + .clone() + .unchecked_into::() + .call(JsValue::null(), evt) + { + tracing::error!("Error in on_manifest_loaded callback: {:?}", err); + } + } + } + } +} diff --git a/frontend/player/src/player/mod.rs b/frontend/player/src/player/mod.rs new file mode 100644 index 00000000..57553072 --- /dev/null +++ b/frontend/player/src/player/mod.rs @@ -0,0 +1,187 @@ +use tokio::sync::broadcast; +use tsify::JsValueSerdeExt; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlVideoElement; + +use self::{ + events::{OnErrorFunction, OnManifestLoadedFunction}, + inner::{NextTrack, PlayerInnerHolder}, + runner::PlayerRunner, +}; + +mod blank; +mod events; +mod fetch; +mod inner; +mod runner; +mod track; +mod util; + +#[wasm_bindgen] +pub struct Player { + shutdown_sender: broadcast::Sender<()>, + inner: PlayerInnerHolder, +} + +impl Default for Player { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Track[]")] + pub type VectorTracks; +} + +#[wasm_bindgen] +impl Player { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + let inner = PlayerInnerHolder::default(); + let (shutdown_sender, _) = broadcast::channel(128); + + Self { + shutdown_sender, + inner, + } + } + + #[wasm_bindgen(setter = lowLatency)] + pub fn set_low_latency(&self, low_latency: bool) { + self.inner.aquire_mut().set_low_latency(low_latency); + } + + #[wasm_bindgen(getter = lowLatency)] + pub fn low_latency(&self) -> bool { + self.inner.aquire().low_latency() + } + + pub fn set_abr_estimate(&self, abr_estimate: Option) { + self.inner.aquire_mut().set_abr_estimate(abr_estimate); + } + + pub fn load(&self, url: &str) -> Result<(), JsValue> { + let mut inner = self.inner.aquire_mut(); + + inner.set_url(url); + + if inner.video_element().is_none() { + return Ok(()); + } + + self.shutdown(); + self.spawn_runner(); + + Ok(()) + } + + #[wasm_bindgen(setter = onerror)] + pub fn set_on_error(&self, f: Option) { + self.inner.aquire_mut().set_on_error(f); + } + + #[wasm_bindgen(getter = onerror)] + pub fn on_error(&self) -> Option { + self.inner.aquire().on_error() + } + + #[wasm_bindgen(getter = tracks)] + pub fn tracks(&self) -> VectorTracks { + self.inner + .aquire() + .tracks() + .iter() + .map(JsValue::from_serde) + .collect::>() + .unwrap() + .unchecked_into() + } + + #[wasm_bindgen(setter = onmanifestloaded)] + pub fn set_on_manifest_loaded(&self, f: Option) { + self.inner.aquire_mut().set_on_manifest_loaded(f); + } + + #[wasm_bindgen(getter = onmanifestloaded)] + pub fn on_manifest_loaded(&self) -> Option { + self.inner.aquire().on_manifest_loaded() + } + + pub fn attach(&self, el: HtmlVideoElement) -> Result<(), JsValue> { + let Ok(element) = el.dyn_into::() else { + return Err(JsValue::from_str("element is not a video element")); + }; + + if let Some(el) = self.inner.aquire().video_element() { + if el.is_same_node(Some(&element)) { + return Err(JsValue::from_str("element is already attached")); + } + } + + self.inner.aquire_mut().set_video_element(Some(element)); + + if self.inner.aquire().url().is_empty() { + return Ok(()); + } + + self.shutdown(); + self.spawn_runner(); + + Ok(()) + } + + pub fn shutdown(&self) { + self.shutdown_sender.send(()).ok(); + } + + /// Gracefully switch to this track id when the current segment is finished. + #[wasm_bindgen(setter = nextTrackId)] + pub fn set_next_track_id(&self, track_id: Option) { + self.inner + .aquire_mut() + .set_next_track_id(track_id.map(NextTrack::Switch)) + } + + /// Get the next track id that will be switched to. + #[wasm_bindgen(getter = nextTrackId)] + pub fn next_track_id(&self) -> Option { + match self.inner.aquire().next_track_id() { + Some(NextTrack::Switch(track_id)) | Some(NextTrack::Force(track_id)) => Some(track_id), + None => None, + } + } + + /// Force switch to this track id immediately. + #[wasm_bindgen(setter = forceTrackId)] + pub fn set_force_track_id(&self, track_id: Option) { + self.inner + .aquire_mut() + .set_next_track_id(track_id.map(NextTrack::Force)) + } + + /// Get the track id that will be forced to switch to. + #[wasm_bindgen(getter = forceTrackId)] + pub fn force_track_id(&self) -> Option { + match self.inner.aquire().next_track_id() { + Some(NextTrack::Force(track_id)) => Some(track_id), + _ => None, + } + } + + /// Get the current track id. + #[wasm_bindgen(getter = trackId)] + pub fn track_id(&self) -> u32 { + self.inner.aquire().active_track_id() + } +} + +impl Player { + fn spawn_runner(&self) { + spawn_local( + PlayerRunner::new(self.inner.clone(), self.shutdown_sender.subscribe()).start(), + ); + } +} diff --git a/frontend/player/src/player/runner.rs b/frontend/player/src/player/runner.rs new file mode 100644 index 00000000..3210057b --- /dev/null +++ b/frontend/player/src/player/runner.rs @@ -0,0 +1,1180 @@ +use std::{ + collections::{HashMap, HashSet}, + pin::pin, +}; + +use crate::{ + hls::{ + self, + master::{MasterPlaylist, Media}, + media::MediaPlaylist, + }, + player::{ + fetch::FetchRequest, + track::{Fragment, ReferenceTrack, TrackResult}, + }, +}; + +use gloo_timers::future::TimeoutFuture; +use mp4::{ + types::{ + ftyp::{FourCC, Ftyp}, + moov::Moov, + }, + BoxType, +}; +use tokio::{ + select, + sync::{broadcast, mpsc}, +}; +use url::Url; +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{HtmlVideoElement, MediaSource, SourceBuffer}; + +use super::{ + blank::VideoFactory, + inner::PlayerInnerHolder, + track::{Track, TrackState}, + util::{register_events, Holder}, +}; + +struct SourceBufferHolder { + sb: Holder, + rx: mpsc::Receiver<()>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum TrackMapping { + Audio, + Video, + AudioVideo, + Buffer, +} + +impl SourceBufferHolder { + fn new(media_source: &MediaSource, codec: &str) -> Result { + let sb = media_source.add_source_buffer(codec)?; + let (tx, rx) = mpsc::channel(128); + + let cleanup = register_events!(sb, { + "updateend" => move |_| { + if tx.try_send(()).is_err() { + tracing::warn!("failed to send updateend event"); + } + } + }); + + Ok(Self { + sb: Holder::new(sb, cleanup), + rx, + }) + } + + fn change_type(&self, codec: &str) -> Result<(), JsValue> { + self.sb.change_type(codec)?; + Ok(()) + } + + async fn append_buffer(&mut self, mut data: Vec) -> Result<(), JsValue> { + self.sb.append_buffer_with_u8_array(data.as_mut_slice())?; + self.rx.recv().await; + Ok(()) + } + + async fn remove(&mut self, start: f64, end: f64) -> Result<(), JsValue> { + self.sb.remove(start, end)?; + self.rx.recv().await; + Ok(()) + } +} + +pub struct PlayerRunner { + inner: PlayerInnerHolder, + track_states: Vec, + + active_track_id: u32, + next_track_id: Option, + + active_reference_track_ids: Vec, + fragment_buffer: HashMap>, + + track_mapping: HashMap, + + moov_map: HashMap, + + force_mapping: HashMap, + + init: bool, + shutdown_recv: broadcast::Receiver<()>, + + low_latency: bool, + + video: Option, + audio: Option, + audiovideo: Option, + + media_source: Holder, + video_element: Holder, + + video_factory: Option, + + evt_recv: mpsc::Receiver<(RunnerEvent, web_sys::Event)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunnerEvent { + VideoError, + VideoPlay, + VideoPause, + VideoSuspend, + VideoStalled, + VideoWaiting, + VideoSeeking, + VideoSeeked, + VideoTimeUpdate, + VideoVolumeChange, + VideoRateChange, + MediaSourceOpen, + MediaSourceClose, + MediaSourceEnded, +} + +fn make_video_holder( + element: HtmlVideoElement, + tx: &mpsc::Sender<(RunnerEvent, web_sys::Event)>, +) -> Holder { + let cleanup = register_events!(element, { + "error" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoError, evt)).is_err() { + tracing::warn!("Video error event dropped"); + } + } + }, + "pause" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoPause, evt)).is_err() { + tracing::warn!("Video pause event dropped"); + } + } + }, + "play" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoPlay, evt)).is_err() { + tracing::warn!("Video play event dropped"); + } + } + }, + "ratechange" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoRateChange, evt)).is_err() { + tracing::warn!("Video ratechange event dropped"); + } + } + }, + "seeked" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoSeeked, evt)).is_err() { + tracing::warn!("Video seeked event dropped"); + } + } + }, + "seeking" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoSeeking, evt)).is_err() { + tracing::warn!("Video seeking event dropped"); + } + } + }, + "stalled" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoStalled, evt)).is_err() { + tracing::warn!("Video stalled event dropped"); + } + } + }, + "suspend" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoSuspend, evt)).is_err() { + tracing::warn!("Video suspend event dropped"); + } + } + }, + "timeupdate" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoTimeUpdate, evt)).is_err() { + tracing::warn!("Video timeupdate event dropped"); + } + } + }, + "volumechange" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoVolumeChange, evt)).is_err() { + tracing::warn!("Video volumechange event dropped"); + } + } + }, + "waiting" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::VideoWaiting, evt)).is_err() { + tracing::warn!("Video waiting event dropped"); + } + } + }, + }); + + Holder::new(element, cleanup) +} + +fn make_media_source_holder( + media_source: MediaSource, + tx: &mpsc::Sender<(RunnerEvent, web_sys::Event)>, +) -> Holder { + let cleanup = register_events!(media_source, { + "sourceclose" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::MediaSourceClose, evt)).is_err() { + tracing::warn!("MediaSource close event dropped") + } + } + }, + "sourceended" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::MediaSourceEnded, evt)).is_err() { + tracing::warn!("MediaSource ended event dropped") + } + } + }, + "sourceopen" => { + let tx = tx.clone(); + move |evt| { + if tx.try_send((RunnerEvent::MediaSourceOpen, evt)).is_err() { + tracing::warn!("MediaSource open event dropped") + } + } + }, + }); + + Holder::new(media_source, cleanup) +} + +impl PlayerRunner { + pub fn new(inner: PlayerInnerHolder, shutdown_recv: broadcast::Receiver<()>) -> Self { + let ms = MediaSource::new().unwrap(); + + let (tx, rx) = mpsc::channel(128); + + let video_element = make_video_holder(inner.aquire().video_element().unwrap(), &tx); + let media_source = make_media_source_holder(ms, &tx); + + Self { + inner, + track_states: Vec::new(), + shutdown_recv, + active_track_id: 0, + next_track_id: None, + moov_map: HashMap::new(), + init: false, + fragment_buffer: HashMap::new(), + active_reference_track_ids: Vec::new(), + track_mapping: HashMap::new(), + force_mapping: HashMap::new(), + low_latency: false, + audio: None, + video: None, + audiovideo: None, + media_source, + video_element, + video_factory: None, + evt_recv: rx, + } + } + + pub async fn start(mut self) { + match self.bind_element().await { + Err(err) => { + tracing::error!("failed to bind element: {:?}", err); + self.inner.aquire().send_error(err.into())(); + return; + } + Ok(true) => {} + Ok(false) => return, + } + + match self.fetch_playlist().await { + Err(err) => { + tracing::error!("failed to handle playlist: {:?}", err); + self.inner.aquire().send_error(err.into())(); + return; + } + Ok(true) => {} + Ok(false) => return, + } + + self.active_track_id = self.inner.aquire().active_track_id(); + + tracing::info!("starting playback"); + + for tid in self.active_track_ids() { + self.track_states.get_mut(tid as usize).unwrap().start(); + } + + 'running: loop { + self.set_low_latency(!self.init); + + let next_track_id = self.inner.aquire().next_track_id(); + if let Some(next_track_id) = next_track_id { + if Some(next_track_id.track_id()) != self.next_track_id { + if self.next_track_id.is_some() { + self.active_track_ids() + .difference(&self.next_track_ids()) + .for_each(|tid| { + self.track_states + .get_mut(*tid as usize) + .unwrap() + .set_stop_at(None); + tracing::trace!("resuming track: {}", tid); + }); + self.next_track_ids() + .difference(&self.active_track_ids()) + .for_each(|tid| { + self.track_states.get_mut(*tid as usize).unwrap().stop(); + tracing::trace!("stopped track: {}", tid); + }); + } + + if next_track_id.track_id() != self.active_track_id { + self.next_track_id = Some(next_track_id.track_id()); + self.next_track_ids() + .difference(&self.active_track_ids()) + .for_each(|tid| { + self.track_states.get_mut(*tid as usize).unwrap().start(); + tracing::trace!("starting track: {}", tid); + }); + } else { + self.next_track_id = None; + self.inner.aquire_mut().set_next_track_id(None); + self.inner + .aquire_mut() + .set_active_track_id(self.active_track_id); + self.fragment_buffer.clear(); + } + } + + if next_track_id.is_force() && self.next_track_id.is_some() { + self.active_track_ids() + .difference(&self.next_track_ids()) + .for_each(|tid| { + self.track_states.get_mut(*tid as usize).unwrap().stop(); + tracing::trace!("stopping track: {}", tid); + match self.track_mapping.get(tid) { + Some(TrackMapping::Audio) => { + self.force_mapping.insert(TrackMapping::Audio, ()) + } + Some(TrackMapping::Video) => { + self.force_mapping.insert(TrackMapping::Video, ()) + } + Some(TrackMapping::AudioVideo) => { + self.force_mapping.insert(TrackMapping::AudioVideo, ()) + } + _ => None, + }; + }); + } + } + + if let Some(next_track_id) = self.next_track_id { + if !self + .track_states + .get(self.active_track_id as usize) + .unwrap() + .running() + || self.active_track_ids().len() == 1 + { + self.active_track_id = next_track_id; + self.next_track_id = None; + self.make_init_seq(None).await.unwrap(); + self.inner + .aquire_mut() + .set_active_track_id(self.active_track_id); + self.inner.aquire_mut().set_next_track_id(None); + tracing::trace!("switched to track: {}", self.active_track_id); + } + } + + for tid in self.active_track_ids().union(&self.next_track_ids()) { + match self.track_states.get_mut(*tid as usize).unwrap().run() { + Ok(Some(result)) => match result { + TrackResult::Init { moov } => { + self.moov_map.insert(*tid, moov); + if let Err(err) = self.make_init_seq(Some(*tid)).await { + tracing::error!("failed to make init seq: {:?}", err); + self.inner.aquire().send_error(err.into())(); + break 'running; + } + } + TrackResult::Media { + fragments, + start_time, + end_time, + } => { + if let Err(err) = self + .handle_fragments(*tid, fragments, start_time, end_time) + .await + { + tracing::error!("failed to handle media: {:?}", err); + self.inner.aquire().send_error(err.into())(); + break 'running; + } + } + }, + Ok(None) => {} + Err(err) => { + tracing::error!("failed to run track: {:?}", err); + self.inner.aquire().send_error(err.into())(); + break 'running; + } + } + } + + let mut loop_timer = pin!(TimeoutFuture::new(0)); + loop { + select! { + _ = &mut loop_timer => { + break; + } + _ = self.shutdown_recv.recv() => { + break 'running; + } + evt = self.evt_recv.recv() => { + tracing::info!("got event: {:?}", evt); + } + } + } + } + + tracing::info!("playback stopped"); + } + + async fn handle_fragments( + &mut self, + tid: u32, + fragments: Vec, + start_time: f64, + end_time: f64, + ) -> Result<(), JsValue> { + if self.next_track_id == Some(tid) { + // We have the next track data from start_time so we can stop using the old track + self.active_track_ids() + .difference(&self.next_track_ids()) + .for_each(|tid| { + let track = self.track_states.get_mut(*tid as usize).unwrap(); + if track.stop_at().is_none() { + track.set_stop_at(Some(start_time)); + } + }); + } + + tracing::trace!( + "tid: {} start_time: {} end_time: {}", + tid, + start_time, + end_time + ); + + // If the track is not active we are going to buffer the fragments. + if !self.track_mapping.contains_key(&tid) { + return Err(JsValue::from_str(&format!("track: {} is not active", tid))); + } + + if matches!(self.track_mapping.get(&tid).unwrap(), TrackMapping::Buffer) { + self.fragment_buffer + .entry(tid) + .or_insert_with(Vec::new) + .extend(fragments); + return Ok(()); + } + + let mut data = Vec::new(); + fragments.iter().for_each(|fragment| { + fragment.moof.mux(&mut data).unwrap(); + fragment.mdat.mux(&mut data).unwrap(); + }); + + let mut forced = false; + + match self.track_mapping.get(&tid).unwrap() { + TrackMapping::Audio => { + if self.force_mapping.remove(&TrackMapping::Audio).is_some() { + self.audio.as_mut().unwrap().remove(0.0, start_time).await?; + forced = true; + } else { + self.audio + .as_mut() + .unwrap() + .remove(0.0, start_time - 30.0) + .await?; + } + self.audio.as_mut().unwrap().append_buffer(data).await?; + + if let Some(video_factory) = &mut self.video_factory { + let mut data = Vec::new(); + fragments.iter().for_each(|fragment| { + let decode_time = fragment.moof.traf[0] + .tfdt + .as_ref() + .unwrap() + .base_media_decode_time; + let duration = fragment.moof.traf[0].duration(); + + let (moof, mdat) = video_factory.moof_mdat(decode_time, duration); + moof.mux(&mut data).unwrap(); + mdat.mux(&mut data).unwrap(); + }); + + self.video.as_mut().unwrap().append_buffer(data).await?; + } + } + TrackMapping::Video => { + if self.force_mapping.remove(&TrackMapping::Video).is_some() { + self.video.as_mut().unwrap().remove(0.0, start_time).await?; + forced = true; + } else { + self.video + .as_mut() + .unwrap() + .remove(0.0, start_time - 30.0) + .await?; + } + self.video.as_mut().unwrap().append_buffer(data).await?; + } + TrackMapping::AudioVideo => { + if self + .force_mapping + .remove(&TrackMapping::AudioVideo) + .is_some() + { + self.audiovideo + .as_mut() + .unwrap() + .remove(0.0, start_time) + .await?; + forced = true; + } else { + self.audiovideo + .as_mut() + .unwrap() + .remove(0.0, start_time - 30.0) + .await?; + } + self.audiovideo + .as_mut() + .unwrap() + .append_buffer(data) + .await?; + } + TrackMapping::Buffer => unreachable!(), + } + + if forced { + let current_time = self.inner.aquire().video_element().unwrap().current_time(); + + if current_time > start_time && current_time < end_time - 0.1 { + // Slight hack to push the video forward and prevent it from getting stuck + self.video_element.set_current_time(current_time + 0.1); + } else { + self.inner + .aquire() + .video_element() + .unwrap() + .set_current_time(start_time); + } + } + + self.autoplay().await; + + Ok(()) + } + + async fn fetch_playlist(&mut self) -> Result { + let Ok(input_url) = Url::parse(self.inner.aquire().url()) else { + return Err(JsValue::from_str(&format!("failed to parse url: {}", self.inner.aquire().url()))); + }; + + let req = FetchRequest::new("GET", input_url.as_str()) + .header("Accept", "application/vnd.apple.mpegurl") + .set_timeout(2000) + .start()?; + + let data = select! { + r = req.wait_result() => { + r? + } + _ = self.shutdown_recv.recv() => { + return Ok(false); + } + }; + + let playlist = match hls::Playlist::try_from(data.as_slice()) { + Ok(playlist) => playlist, + Err(err) => return Err(JsValue::from_str(&err)), + }; + + // We now need to determine what kind of playlist we have, if we have a master playlist we need to do some ABR logic to determine what variant to use + // If we have a media playlist we can just start playing it directly. + match playlist { + hls::Playlist::Master(playlist) => self.handle_master_playlist(input_url, playlist)?, + hls::Playlist::Media(playlist) => self.handle_media_playlist(input_url, playlist)?, + } + + Ok(true) + } + + async fn make_init_seq(&mut self, for_tid: Option) -> Result<(), JsValue> { + let active_tracks = self.active_track_ids(); + if active_tracks.len() > 2 { + return Err(JsValue::from_str( + "too many active tracks, currently only 2 are supported", + )); + } + + let next_tracks = self.next_track_ids(); + if next_tracks.len() > 2 { + return Err(JsValue::from_str( + "too many next tracks, currently only 2 are supported", + )); + } + + tracing::trace!( + "active_tracks: {:?} next_tracks: {:?}, for_tid: {:?}", + active_tracks, + next_tracks, + for_tid + ); + + let diff = next_tracks + .difference(&active_tracks) + .collect::>(); + + for (tid, moov) in self + .moov_map + .clone() + .iter() + .filter(|(tid, _)| active_tracks.contains(tid) || next_tracks.contains(tid)) + { + if let Some(for_tid) = for_tid { + if *tid != for_tid { + continue; + } + } + + if diff.contains(tid) { + self.track_mapping.insert(*tid, TrackMapping::Buffer); + continue; + } + + let track = self.track_states.get(*tid as usize).unwrap().track(); + + let (sb, mapping) = if moov.traks.is_empty() { + return Err(JsValue::from_str("no tracks in moov")); + } else if moov.traks.len() == 1 + && (!track.referenced_group_ids.is_empty() || track.reference.is_some()) + { + if self.audiovideo.is_some() { + return Err(JsValue::from_str("audiovideo track already exists")); + } + + let trak = moov.traks.get(0).unwrap(); + let codecs = trak.mdia.minf.stbl.stsd.get_codecs().collect::>(); + if trak.mdia.minf.stbl.stsd.is_audio() { + // We have an audio track + let codec = format!("audio/mp4; codecs=\"{}\"", &codecs.join(",")); + if self.audio.is_none() { + self.audio = Some(SourceBufferHolder::new(&self.media_source, &codec)?); + self.video = Some(SourceBufferHolder::new( + &self.media_source, + "video/mp4; codecs=\"avc1.4d002a\"", + )?); + } + + if self.active_track_ids().len() == 1 { + let video_factory = VideoFactory::new(trak.mdia.mdhd.timescale); + + let codecs = video_factory.moov().traks[0] + .mdia + .minf + .stbl + .stsd + .get_codecs() + .collect::>(); + + let video = self.video.as_mut().unwrap(); + video + .change_type(&format!("video/mp4; codecs=\"{}\"", codecs.join(",")))?; + + self.video_factory = Some(video_factory); + } else { + self.video_factory = None; + } + + let audio = self.audio.as_mut().unwrap(); + audio.change_type(&codec)?; + (audio, TrackMapping::Audio) + } else if trak.mdia.minf.stbl.stsd.is_video() { + // We have a video track + let codec = format!("video/mp4; codecs=\"{}\"", &codecs.join(",")); + if self.video.is_none() { + self.video = Some(SourceBufferHolder::new(&self.media_source, &codec)?); + self.audio = Some(SourceBufferHolder::new( + &self.media_source, + "audio/mp4; codecs=\"mp4a.40.2\"", + )?); + } + + if self.active_track_ids().len() == 1 { + return Err(JsValue::from_str( + "video track must be paired with audio track", + )); + } else { + self.video_factory = None; + } + + let video = self.video.as_mut().unwrap(); + video.change_type(&codec)?; + (video, TrackMapping::Video) + } else { + return Err(JsValue::from_str("unsupported track type")); + } + } else { + if self.video.is_some() || self.audio.is_some() { + return Err(JsValue::from_str("audio or video track already exists")); + } + + self.video_factory = None; + + // We have both audio and video tracks + let audio_trak = moov + .traks + .iter() + .find(|trak| trak.mdia.minf.stbl.stsd.is_audio()); + let video_trak = moov + .traks + .iter() + .find(|trak| trak.mdia.minf.stbl.stsd.is_video()); + + if audio_trak.is_none() && video_trak.is_none() { + return Err(JsValue::from_str("missing audio and video track")); + } + + let mut codecs = Vec::new(); + + if let Some(audio_trak) = audio_trak { + let audio_codecs = audio_trak.mdia.minf.stbl.stsd.get_codecs(); + codecs.extend(audio_codecs); + } + + if let Some(video_trak) = video_trak { + let video_codecs = video_trak.mdia.minf.stbl.stsd.get_codecs(); + codecs.extend(video_codecs); + } + + let codec = format!("video/mp4; codecs=\"{}\"", &codecs.join(",")); + + if self.audiovideo.is_none() { + self.audiovideo = Some(SourceBufferHolder::new(&self.media_source, &codec)?); + } + + let audiovideo = self.audiovideo.as_mut().unwrap(); + audiovideo.change_type(&codec)?; + (audiovideo, TrackMapping::AudioVideo) + }; + + // Construct a moov segment + let mut data = Vec::new(); + Ftyp::new(FourCC::Iso5, 512, vec![FourCC::Iso5, FourCC::Iso6]) + .mux(&mut data) + .unwrap(); + moov.mux(&mut data).unwrap(); + + sb.append_buffer(data).await?; + + if let Some(video_factory) = &self.video_factory { + let mut data = Vec::new(); + Ftyp::new(FourCC::Iso5, 512, vec![FourCC::Iso5, FourCC::Iso6]) + .mux(&mut data) + .unwrap(); + video_factory.moov().mux(&mut data).unwrap(); + + self.video.as_mut().unwrap().append_buffer(data).await?; + } + + if matches!( + self.track_mapping.insert(*tid, mapping), + Some(TrackMapping::Buffer) + ) { + let fragments = self.fragment_buffer.remove(tid).unwrap_or_default(); + let start_time = fragments.first().map(|f| f.start_time).unwrap_or_default(); + let end_time = fragments.last().map(|f| f.end_time).unwrap_or_default(); + + self.handle_fragments(*tid, fragments, start_time, end_time) + .await?; + } + } + + Ok(()) + } + + async fn autoplay(&mut self) { + if self.init { + return; + } + + let fut = { + let inner = self.inner.aquire(); + let element = inner.video_element().unwrap(); + let Ok(start) = element.buffered().start(0) else { + return; + }; + + self.init = true; + + element.set_current_time(start); + element.play().map(JsFuture::from) + }; + + if let Ok(fut) = fut { + fut.await.ok(); + } + } + + async fn bind_element(&mut self) -> Result { + let url = web_sys::Url::create_object_url_with_source(&self.media_source)?; + + self.video_element.set_src(&url); + + let mut result = Ok(true); + + let mut global_evt = self.shutdown_recv.resubscribe(); + + 'l: loop { + select! { + _ = global_evt.recv() => { + result = Ok(false); + break 'l; + } + evt = self.evt_recv.recv() => { + match evt { + Some((RunnerEvent::MediaSourceOpen, _)) => { + break 'l; + } + Some((RunnerEvent::MediaSourceClose, _)) => { + result = Err(JsValue::from_str("media source closed")); + break 'l; + } + Some((RunnerEvent::MediaSourceEnded, _)) => { + result = Err(JsValue::from_str("media source ended")); + break 'l; + } + None => unreachable!(), + _ => {} + } + } + } + } + + web_sys::Url::revoke_object_url(&url)?; + + result + } + + fn set_low_latency(&mut self, force: bool) { + let low_latency = self.inner.aquire().low_latency(); + if self.low_latency != low_latency || force { + self.low_latency = low_latency; + self.track_states.iter_mut().for_each(|track| { + track.set_low_latency(low_latency); + }); + + let buffered = self.inner.aquire().video_element().unwrap().buffered(); + if buffered.length() != 0 { + self.inner + .aquire() + .video_element() + .unwrap() + .set_current_time( + (if low_latency { + buffered + .end(buffered.length() - 1) + .map(|t| t - 0.1) + .unwrap_or_default() + } else { + buffered + .end(buffered.length() - 1) + .map(|t| t - 2.0) + .unwrap_or_default() + }) + .max(0.0), + ) + } + } + } + + fn active_track_ids(&self) -> HashSet { + self.track_ids(self.active_track_id) + } + + fn next_track_ids(&self) -> HashSet { + self.next_track_id + .map(|id| self.track_ids(id)) + .unwrap_or_default() + } + + fn track_ids(&self, track_id: u32) -> HashSet { + let active_track = self.track_states.get(track_id as usize).unwrap(); + + let mut track_ids = active_track + .track() + .referenced_group_ids + .iter() + .map(|id| *self.active_reference_track_ids.get(*id as usize).unwrap()) + .collect::>(); + + track_ids.insert(track_id); + + track_ids + } + + fn handle_master_playlist( + &mut self, + input_url: Url, + mut playlist: MasterPlaylist, + ) -> Result<(), JsValue> { + let mut inner = self.inner.aquire_mut(); + + let mut reference_streams = HashSet::new(); + + for stream in playlist.streams.iter() { + if let Some(audio) = stream.audio.as_ref() { + reference_streams.insert(audio); + } + + if let Some(video) = stream.video.as_ref() { + reference_streams.insert(video); + } + } + + let mut m3u8_url_to_track = HashMap::new(); + + enum TrackReference { + Flat(Media), + Reference(u32), + } + + let mut reference_tracks = HashMap::new(); + let mut current_track_idx = 0; + let mut group_id = 0; + + for stream in reference_streams.into_iter() { + let Some(groups) = playlist.groups.get_mut(stream) else { + return Err(JsValue::from_str(&format!("failed to find group for stream: {}", stream))); + }; + + let pos = groups.iter().position(|item| item.default).unwrap_or(0); + groups.iter_mut().for_each(|item| item.default = false); + + let default_item = &mut groups[pos]; + if default_item.uri.is_empty() { + // This is a reference track but is not really a reference track + reference_tracks.insert(stream.clone(), TrackReference::Flat(default_item.clone())); + continue; + } + + default_item.default = true; + + // Otherwise this is actually a reference track + // So we need to generate a new track id for it. + let mut ids = HashSet::new(); + for track in groups { + let url = match Url::parse(&track.uri).or_else(|_| input_url.join(&track.uri)) { + Ok(url) => url, + Err(err) => { + return Err(JsValue::from_str(&format!("failed to parse url: {}", err))); + } + }; + + let track_id = m3u8_url_to_track + .entry(url.clone()) + .or_insert_with(|| { + let t = Track { + id: current_track_idx, + is_variant_track: false, + playlist_url: url.clone(), + referenced_group_ids: Vec::new(), + name: Some(track.name.clone()), + bandwidth: None, + codecs: None, + frame_rate: None, + height: None, + width: None, + reference: Some(ReferenceTrack { + group_id: group_id as u32, + is_default: track.default, + }), + }; + + current_track_idx += 1; + + t + }) + .id; + + if track.default { + self.active_reference_track_ids.push(track_id); + } + + ids.insert(track_id); + } + + reference_tracks.insert(stream.clone(), TrackReference::Reference(group_id as u32)); + group_id += 1; + } + + for stream in playlist.streams.iter() { + let url = match Url::parse(&stream.uri).or_else(|_| input_url.join(&stream.uri)) { + Ok(url) => url, + Err(err) => { + return Err(JsValue::from_str(&format!("failed to parse url: {}", err))); + } + }; + + let track = m3u8_url_to_track.entry(url.clone()).or_insert_with(|| { + let t = Track { + id: current_track_idx, + is_variant_track: true, + playlist_url: url.clone(), + referenced_group_ids: Vec::new(), + name: None, + reference: None, + bandwidth: Some(stream.bandwidth), + codecs: stream.codecs.clone(), + frame_rate: stream.frame_rate, + width: stream.resolution.map(|r| r.0), + height: stream.resolution.map(|r| r.1), + }; + + current_track_idx += 1; + + t + }); + + track.bandwidth = Some(stream.bandwidth); + track.codecs = stream.codecs.clone(); + track.frame_rate = stream.frame_rate; + track.width = stream.resolution.map(|r| r.0); + track.height = stream.resolution.map(|r| r.1); + track.is_variant_track = true; + + if let Some(audio) = stream.audio.as_ref() { + match reference_tracks.get(audio) { + Some(TrackReference::Flat(media)) => { + track.name = Some(media.name.clone()); + } + Some(TrackReference::Reference(group_id)) => { + if track.reference.as_ref().map(|t| t.group_id) != Some(*group_id) { + track.referenced_group_ids.push(*group_id); + } + } + None => { + return Err(JsValue::from_str(&format!( + "failed to find reference track for audio: {}", + audio + ))); + } + } + } + + if let Some(video) = stream.video.as_ref() { + match reference_tracks.get(video) { + Some(TrackReference::Flat(media)) => { + track.name = Some(media.name.clone()); + } + Some(TrackReference::Reference(group_id)) => { + if track.reference.as_ref().map(|t| t.group_id) != Some(*group_id) { + track.referenced_group_ids.push(*group_id); + } + } + None => { + return Err(JsValue::from_str(&format!( + "failed to find reference track for video: {}", + video + ))); + } + } + } + } + + let mut tracks = m3u8_url_to_track.into_values().collect::>(); + tracks.sort_by(|a, b| a.id.cmp(&b.id)); + + self.track_states = tracks.clone().into_iter().map(TrackState::new).collect(); + + let fire_event = inner.set_tracks(tracks, true); + inner.set_active_reference_track_ids(self.active_reference_track_ids.clone()); + inner.set_active_track_id(0); + + drop(inner); + + fire_event(); + + Ok(()) + } + + fn handle_media_playlist( + &mut self, + input_url: Url, + playlist: MediaPlaylist, + ) -> Result<(), JsValue> { + let mut inner = self.inner.aquire_mut(); + + let track = Track { + id: 0, + bandwidth: None, + is_variant_track: true, + name: None, + playlist_url: input_url, + referenced_group_ids: Vec::new(), + reference: None, + codecs: None, + frame_rate: None, + height: None, + width: None, + }; + + let mut track_state = TrackState::new(track.clone()); + track_state.set_playlist(playlist); + + self.track_states = vec![track_state]; + + let fire_event = inner.set_tracks(vec![track], false); + inner.set_active_track_id(0); + + drop(inner); + fire_event(); + + Ok(()) + } +} diff --git a/frontend/player/src/player/track.rs b/frontend/player/src/player/track.rs new file mode 100644 index 00000000..8446a1b0 --- /dev/null +++ b/frontend/player/src/player/track.rs @@ -0,0 +1,496 @@ +use std::{collections::VecDeque, io}; + +use bytes::{Buf, Bytes}; +use mp4::{ + types::{mdat::Mdat, moof::Moof, moov::Moov}, + DynBox, +}; +use serde::Serialize; +use tsify::Tsify; +use url::Url; +use wasm_bindgen::JsValue; +use web_sys::window; + +use crate::hls::{self, media::MediaPlaylist}; + +use super::fetch::{FetchRequest, InflightRequest}; + +#[derive(Tsify, Debug, Clone, Serialize)] +#[tsify(into_wasm_abi)] +pub struct Track { + pub id: u32, + pub bandwidth: Option, + pub name: Option, + pub playlist_url: Url, + pub referenced_group_ids: Vec, + pub is_variant_track: bool, + pub codecs: Option, + pub width: Option, + pub height: Option, + pub frame_rate: Option, + pub reference: Option, +} + +#[derive(Tsify, Debug, Clone, Serialize)] +#[tsify(into_wasm_abi)] +pub struct ReferenceTrack { + pub group_id: u32, + pub is_default: bool, +} + +pub struct TrackRequest { + req: InflightRequest, + is_init: bool, +} + +pub struct TrackState { + track: Track, + playlist_req: Option, + playlist: Option, + + requests: VecDeque, + + current_sn: u32, + current_part: u32, + current_map_sn: u32, + + running: bool, + low_latency: bool, + next_playlist_req_time: f64, + last_fetch_delay: f64, + last_playlist_fetch: f64, + + last_end_time: f64, + + stop_at: Option, + + track_info: Option, +} + +fn now() -> f64 { + window().unwrap().performance().unwrap().now() +} + +fn get_url(playlist_url: &str, url: &str) -> Result { + Url::parse(url).or_else(|_| { + let playlist_url = Url::parse(playlist_url).map_err(|_| "invalid playlist url")?; + let url = playlist_url.join(url).map_err(|_| "invalid url")?; + + Ok(url) + }) +} + +pub enum TrackResult { + Init { + moov: Moov, + }, + Media { + fragments: Vec, + start_time: f64, + end_time: f64, + }, +} + +pub struct Fragment { + pub moof: Moof, + pub mdat: Mdat, + pub start_time: f64, + pub end_time: f64, +} + +struct TrackInfo { + timescale: u32, +} + +fn demux_mp4_boxes(mut cursor: io::Cursor) -> Result, JsValue> { + Ok((0..) + .map_while(|_| { + if cursor.has_remaining() { + Some(DynBox::demux(&mut cursor)) + } else { + None + } + }) + .take_while(|r| r.is_ok()) + .collect::, _>>() + .map_err(|_| "invalid init segment")?) +} + +impl TrackState { + pub fn new(track: Track) -> Self { + Self { + track, + playlist_req: None, + running: false, + playlist: None, + requests: VecDeque::new(), + current_sn: 0, + current_map_sn: 0, + next_playlist_req_time: 0.0, + last_playlist_fetch: 0.0, + last_fetch_delay: 0.0, + low_latency: false, + current_part: 0, + last_end_time: 0.0, + stop_at: None, + + track_info: None, + } + } + + pub fn running(&self) -> bool { + self.running + } + + pub fn run(&mut self) -> Result, JsValue> { + if !self.running { + return Ok(None); + } + + if let Some(req) = self.handle_requests()? { + // We have something to yeild to the caller. + let data = req.req.result()?.expect("request should be done"); + if req.is_init { + let boxes = demux_mp4_boxes(io::Cursor::new(Bytes::from(data)))?; + + if boxes.is_empty() { + return Err("invalid init segment, missing ftyp".into()); + } + + match &boxes[0] { + DynBox::Ftyp(ftyp) => ftyp, + _ => { + return Err("invalid init segment, missing ftyp".into()); + } + }; + + let moov = boxes + .into_iter() + .find_map(|b| match b { + DynBox::Moov(moov) => Some(moov), + _ => None, + }) + .ok_or("invalid init segment, missing moov")?; + + let Some(trak) = moov.traks.get(0) else { + return Err("invalid init segment, missing trak".into()); + }; + + self.track_info = Some(TrackInfo { + timescale: trak.mdia.mdhd.timescale, + }); + + Ok(Some(TrackResult::Init { moov })) + } else { + let Some(track_info) = &self.track_info else { + return Err("missing track info".into()); + }; + + let boxes = demux_mp4_boxes(io::Cursor::new(Bytes::from(data)))?; + + let mut fragments = Vec::new(); + + let mut keyframe = 0; + + // Convert the boxes vector into a tuple of moof and mdat + let boxes = boxes + .into_iter() + .filter_map(|b| match b { + DynBox::Moof(moof) => Some((Some(moof), None)), + DynBox::Mdat(mdat) => Some((None, Some(mdat))), + _ => None, + }) + .try_fold::<_, _, Result<_, &'static str>>( + Vec::<(Moof, Option)>::new(), + |mut acc, (moof, mdat)| { + if let Some(moof) = moof { + if let Some(last) = acc.last() { + if last.1.is_none() { + return Err("invalid media segment, missing mdat"); + } + } + + acc.push((moof, None)); + } + + if let Some(mdat) = mdat { + if acc.is_empty() || acc.last().unwrap().1.is_some() { + return Err("invalid media segment, missing moof"); + } + + acc.last_mut().unwrap().1 = Some(mdat); + } + + Ok(acc) + }, + )?; + + for (moof, mdat) in boxes { + let Some(traf) = moof.traf.get(0) else { + return Err("invalid media segment, missing traf".into()); + }; + + if traf.contains_keyframe() { + keyframe += 1; + } + + // This will tell us when the fragment starts + let base_decode_time = traf + .tfdt + .as_ref() + .map(|tfdt| tfdt.base_media_decode_time) + .unwrap_or_default(); + // This will tell us how long the fragment is (in timescales) + let duration = traf.duration() as u64; + + let start_time = base_decode_time as f64 / track_info.timescale as f64; + let end_time = + (base_decode_time + duration) as f64 / track_info.timescale as f64; + + fragments.push(Fragment { + moof, + mdat: mdat.unwrap(), + start_time, + end_time, + }); + } + + let start_time = fragments.first().map(|f| f.start_time).unwrap_or_default(); + let end_time = fragments.last().map(|f| f.end_time).unwrap_or_default(); + + self.last_end_time = end_time; + + if let Some(stop_at) = self.stop_at { + if end_time >= stop_at && keyframe > 0 { + self.stop(); + } + } + + Ok(Some(TrackResult::Media { + fragments, + start_time, + end_time, + })) + } + } else { + self.request_playlist()?; + self.handle_playlist()?; + + Ok(None) + } + } + + fn handle_requests(&mut self) -> Result, JsValue> { + let Some(req) = self.requests.front() else { + return Ok(None); + }; + + if req.req.is_done() { + Ok(self.requests.pop_front()) + } else { + Ok(None) + } + } + + pub fn set_stop_at(&mut self, stop_at: Option) { + self.stop_at = stop_at; + } + + pub fn set_low_latency(&mut self, low_latency: bool) { + self.low_latency = low_latency; + } + + pub fn stop_at(&self) -> Option { + self.stop_at + } + + fn handle_playlist(&mut self) -> Result<(), JsValue> { + let Some(playlist) = &self.playlist else { + return Ok(()); + }; + + if self.current_sn < playlist.media_sequence { + if let Some(segment) = playlist.segments.iter().rev().find(|s| s.map.is_some()) { + let url = get_url( + self.track.playlist_url.as_str(), + segment.map.as_deref().unwrap(), + )?; + self.requests.push_back(TrackRequest { + req: FetchRequest::new("GET", url.as_str()) + .header("Accept", "video/mp4") + .set_timeout(2000) + .start()?, + is_init: true, + }); + self.current_map_sn = segment.sn; + } + + self.current_sn = if self.low_latency { + let last_sn = playlist + .segments + .last() + .map(|s| s.sn) + .unwrap_or(playlist.media_sequence); + last_sn + } else { + let segments = playlist + .segments + .iter() + .rev() + .filter_map(|s| if s.url.is_empty() { None } else { Some(s.sn) }) + .collect::>(); + match segments.len() { + 0 => playlist.media_sequence, + 1 => segments[0], + _ => segments[1], + } + }; + self.current_part = 0; + } + + for segment in &playlist.segments { + if segment.sn < self.current_sn { + continue; + } + + if let Some(map) = &segment.map { + if self.current_map_sn < segment.sn { + let url = get_url(self.track.playlist_url.as_str(), map)?; + self.requests.push_back(TrackRequest { + req: FetchRequest::new("GET", url.as_str()) + .header("Accept", "video/mp4") + .set_timeout(2000) + .start()?, + is_init: true, + }); + } + } + + self.current_map_sn = segment.sn; + + if segment.url.is_empty() && (!self.low_latency || segment.parts.is_empty()) { + continue; + } + + let url = if self.low_latency && segment.parts.len() > self.current_part as usize { + let part = &segment.parts[self.current_part as usize]; + self.current_part += 1; + self.last_fetch_delay = part.duration * 1000.0 / 2.0; + part.uri.as_str() + } else if !segment.url.is_empty() { + self.current_part = 0; + self.current_sn = segment.sn + 1; + self.last_fetch_delay = segment.duration / 2.0 * 1000.0; + segment.url.as_str() + } else { + continue; + }; + + let url = get_url(self.track.playlist_url.as_str(), url)?; + self.requests.push_back(TrackRequest { + req: FetchRequest::new("GET", url.as_str()) + .header("Accept", "video/mp4") + .set_timeout(2000) + .start()?, + is_init: false, + }); + + if self.low_latency + && segment.parts.len() == self.current_part as usize + && !segment.url.is_empty() + { + // We are finished with this segment + self.current_sn = segment.sn + 1; + self.current_part = 0; + continue; + } + } + + // If the playlist has an end list tag we don't need to request it again + if playlist.end_list { + self.next_playlist_req_time = -1.0; + } else if self.next_playlist_req_time == -1.0 { + self.next_playlist_req_time = now() + self.last_fetch_delay; + } + + Ok(()) + } + + fn request_playlist(&mut self) -> Result<(), JsValue> { + if let Some(req) = self.playlist_req.as_ref() { + if let Some(result) = req.result()? { + match hls::Playlist::try_from(result.as_slice())? { + hls::Playlist::Media(playlist) => { + self.playlist = Some(playlist); + } + _ => { + return Err("invalid playlist".into()); + } + } + + self.playlist_req = None; + self.next_playlist_req_time = -1.0; + self.last_playlist_fetch = now(); + } + } else if self.next_playlist_req_time != -1.0 { + if self.low_latency + && self + .playlist + .as_ref() + .and_then(|p| p.server_control.as_ref().map(|s| s.can_block_reload)) + .unwrap_or_default() + { + // Low latency request + let mut url = self.track.playlist_url.clone(); + + url.query_pairs_mut() + .append_pair("_HLS_msn", self.current_sn.to_string().as_str()); + url.query_pairs_mut() + .append_pair("_HLS_part", self.current_part.to_string().as_str()); + + self.playlist_req = Some( + FetchRequest::new("GET", url.as_str()) + .header("Accept", "application/vnd.apple.mpegurl") + .set_timeout(2000) + .start()?, + ); + } else if now() >= self.next_playlist_req_time { + self.playlist_req = Some( + FetchRequest::new("GET", self.track.playlist_url.as_str()) + .header("Accept", "application/vnd.apple.mpegurl") + .set_timeout(2000) + .start()?, + ); + } + } + + Ok(()) + } + + pub fn track(&self) -> &Track { + &self.track + } + + pub fn start(&mut self) { + self.running = true; + self.stop_at = None; + } + + pub fn stop(&mut self) { + self.running = false; + self.playlist = None; + self.playlist_req = None; + self.stop_at = None; + self.current_part = 0; + self.current_sn = 0; + self.last_end_time = 0.0; + self.last_fetch_delay = 0.0; + self.last_playlist_fetch = 0.0; + self.next_playlist_req_time = 0.0; + self.requests.clear(); + } + + pub fn set_playlist(&mut self, playlist: MediaPlaylist) { + self.playlist = Some(playlist); + } +} diff --git a/frontend/player/src/player/util.rs b/frontend/player/src/player/util.rs new file mode 100644 index 00000000..c00d041f --- /dev/null +++ b/frontend/player/src/player/util.rs @@ -0,0 +1,69 @@ +use std::ops::{Deref, DerefMut}; + +use wasm_bindgen::JsCast; + +type Cleanup = Box; + +pub struct Holder { + inner: T, + cleanup: Option, +} + +impl Holder { + pub fn new(inner: T, cleanup: Cleanup) -> Self { + Self { + inner, + cleanup: Some(cleanup), + } + } +} + +impl Deref for Holder { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Holder { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Drop for Holder { + fn drop(&mut self) { + if let Some(cleanup) = self.cleanup.take() { + cleanup(self.inner.unchecked_ref()); + } + } +} + +macro_rules! register_events { + ($ob:ident, { + $( + $($evt:literal)|+ => $body:expr + ),* $(,)? + }) => { + { + let mut handlers = std::collections::VecDeque::new(); + $( + handlers.push_back((vec![$($evt.to_string()),+], Closure::::new($body))); + $( + $ob.add_event_listener_with_callback($evt, handlers.back().unwrap().1.as_ref().unchecked_ref()).unwrap(); + )* + )* + + Box::new(move |val: &web_sys::EventTarget| { + handlers.drain(..).for_each(|(evts, cb)| { + for evt in evts { + val.remove_event_listener_with_callback(&evt, cb.as_ref().unchecked_ref()).unwrap(); + } + }); + }) as Box + } + }; +} + +pub(super) use register_events; diff --git a/frontend/player/src/tracing_wasm.rs b/frontend/player/src/tracing_wasm.rs new file mode 100644 index 00000000..36eb61e4 --- /dev/null +++ b/frontend/player/src/tracing_wasm.rs @@ -0,0 +1,264 @@ +// Taken from https://github.com/old-storyai/tracing-wasm in order to implement different logging levels. + +use core::fmt::{self, Write}; +use core::sync::atomic::AtomicUsize; + +use tracing::field::{Field, Visit}; +use tracing::Subscriber; +use tracing_subscriber::layer::*; +use tracing_subscriber::registry::*; + +use wasm_bindgen::JsValue; +use web_sys::{console, window}; + +fn mark(name: &str) { + window().unwrap().performance().unwrap().mark(name).unwrap(); +} + +fn measure(name: String, start_mark: String) -> Result<(), JsValue> { + window() + .unwrap() + .performance() + .unwrap() + .measure_with_start_mark(&name, &start_mark) +} + +#[derive(Debug, PartialEq)] +pub struct WASMLayerConfig { + report_logs_in_timings: bool, + report_logs_in_console: bool, + use_console_color: bool, + max_level: tracing::Level, +} + +impl core::default::Default for WASMLayerConfig { + fn default() -> Self { + WASMLayerConfig { + report_logs_in_timings: true, + report_logs_in_console: true, + use_console_color: true, + max_level: tracing::Level::TRACE, + } + } +} + +/// Implements [tracing_subscriber::layer::Layer] which uses [wasm_bindgen] for marking and measuring with `window.performance` +pub struct WASMLayer { + last_event_id: AtomicUsize, + config: WASMLayerConfig, +} + +impl WASMLayer { + pub fn new(config: WASMLayerConfig) -> Self { + WASMLayer { + last_event_id: AtomicUsize::new(0), + config, + } + } +} + +impl core::default::Default for WASMLayer { + fn default() -> Self { + WASMLayer::new(WASMLayerConfig::default()) + } +} + +fn mark_name(id: &tracing::Id) -> String { + format!("t{:x}", id.into_u64()) +} + +impl LookupSpan<'a>> Layer for WASMLayer { + fn enabled(&self, metadata: &tracing::Metadata<'_>, _: Context<'_, S>) -> bool { + let level = metadata.level(); + level <= &self.config.max_level + } + + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::Id, + ctx: Context<'_, S>, + ) { + let mut new_debug_record = StringRecorder::new(); + attrs.record(&mut new_debug_record); + + if let Some(span_ref) = ctx.span(id) { + span_ref + .extensions_mut() + .insert::(new_debug_record); + } + } + + /// doc: Notifies this layer that a span with the given Id recorded the given values. + fn on_record(&self, id: &tracing::Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) { + if let Some(span_ref) = ctx.span(id) { + if let Some(debug_record) = span_ref.extensions_mut().get_mut::() { + values.record(debug_record); + } + } + } + + // /// doc: Notifies this layer that a span with the ID span recorded that it follows from the span with the ID follows. + // fn on_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id, ctx: Context<'_, S>) {} + /// doc: Notifies this layer that an event has occurred. + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + if self.config.report_logs_in_timings || self.config.report_logs_in_console { + let mut recorder = StringRecorder::new(); + event.record(&mut recorder); + let meta = event.metadata(); + let level = meta.level(); + if self.config.report_logs_in_console { + let origin = meta + .file() + .and_then(|file| meta.line().map(|ln| format!("{}:{}", file, ln))) + .unwrap_or_default(); + + if self.config.use_console_color { + let console_fn = match *level { + tracing::Level::TRACE => console::debug_4, + tracing::Level::DEBUG => console::debug_4, + tracing::Level::INFO => console::info_4, + tracing::Level::WARN => console::warn_4, + tracing::Level::ERROR => console::error_4, + }; + console_fn( + &format!("%c{}%c {}%c{}", level, origin, recorder,).into(), + &match *level { + tracing::Level::TRACE => "color: dodgerblue; background: #444", + tracing::Level::DEBUG => "color: lawngreen; background: #444", + tracing::Level::INFO => "color: whitesmoke; background: #444", + tracing::Level::WARN => "color: orange; background: #444", + tracing::Level::ERROR => "color: red; background: #444", + } + .into(), + &"color: gray; font-style: italic".into(), + &"color: inherit".into(), + ); + } else { + let console_fn = match *level { + tracing::Level::TRACE => console::debug_1, + tracing::Level::DEBUG => console::debug_1, + tracing::Level::INFO => console::info_1, + tracing::Level::WARN => console::warn_1, + tracing::Level::ERROR => console::error_1, + }; + + console_fn(&format!("{} {} {}", level, origin, recorder,).into()); + } + } + if self.config.report_logs_in_timings { + let mark_name = format!( + "c{:x}", + self.last_event_id + .fetch_add(1, core::sync::atomic::Ordering::Relaxed) + ); + // mark and measure so you can see a little blip in the profile + mark(&mark_name); + let _ = measure( + format!( + "{} {} {}", + level, + meta.module_path().unwrap_or("..."), + recorder, + ), + mark_name, + ); + } + } + } + /// doc: Notifies this layer that a span with the given ID was entered. + fn on_enter(&self, id: &tracing::Id, _ctx: Context<'_, S>) { + mark(&mark_name(id)); + } + /// doc: Notifies this layer that the span with the given ID was exited. + fn on_exit(&self, id: &tracing::Id, ctx: Context<'_, S>) { + if let Some(span_ref) = ctx.span(id) { + let meta = span_ref.metadata(); + if let Some(debug_record) = span_ref.extensions().get::() { + let _ = measure( + format!( + "\"{}\" {} {}", + meta.name(), + meta.module_path().unwrap_or("..."), + debug_record, + ), + mark_name(id), + ); + } else { + let _ = measure( + format!( + "\"{}\" {}", + meta.name(), + meta.module_path().unwrap_or("..."), + ), + mark_name(id), + ); + } + } + } + // /// doc: Notifies this layer that the span with the given ID has been closed. + // /// We can dispose of any data for the span we might have here... + // fn on_close(&self, _id: tracing::Id, ctx: Context<'_, S>) {} + // /// doc: Notifies this layer that a span ID has been cloned, and that the subscriber returned a different ID. + // /// I'm not sure if I need to do something here... + // fn on_id_change(&self, _old: &tracing::Id, _new: &tracing::Id, ctx: Context<'_, S>) {} +} + +/// Set the global default with [tracing::subscriber::set_global_default] +pub fn set_as_global_default() { + tracing::subscriber::set_global_default( + Registry::default().with(WASMLayer::new(WASMLayerConfig::default())), + ) + .expect("default global"); +} + +struct StringRecorder { + display: String, + is_following_args: bool, +} +impl StringRecorder { + fn new() -> Self { + StringRecorder { + display: String::new(), + is_following_args: false, + } + } +} + +impl Visit for StringRecorder { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + if !self.display.is_empty() { + self.display = format!("{:?}\n{}", value, self.display) + } else { + self.display = format!("{:?}", value) + } + } else { + if self.is_following_args { + // following args + writeln!(self.display).unwrap(); + } else { + // first arg + write!(self.display, " ").unwrap(); + self.is_following_args = true; + } + write!(self.display, "{} = {:?};", field.name(), value).unwrap(); + } + } +} + +impl core::fmt::Display for StringRecorder { + fn fmt(&self, mut f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if !self.display.is_empty() { + write!(&mut f, " {}", self.display) + } else { + Ok(()) + } + } +} + +impl core::default::Default for StringRecorder { + fn default() -> Self { + StringRecorder::new() + } +} diff --git a/frontend/player/src/utils.rs b/frontend/player/src/utils.rs deleted file mode 100644 index cd1d0f33..00000000 --- a/frontend/player/src/utils.rs +++ /dev/null @@ -1,28 +0,0 @@ -use tracing_subscriber::fmt::format::Pretty; -use tracing_subscriber::fmt::time::UtcTime; -use tracing_subscriber::prelude::*; -use tracing_web::{performance_layer, MakeConsoleWriter}; - -pub fn set_panic_hook() { - // When the `console_error_panic_hook` feature is enabled, we can call the - // `set_panic_hook` function at least once during initialization, and then - // we will get better error messages if our code ever panics. - // - // For more details see - // https://github.com/rustwasm/console_error_panic_hook#readme - #[cfg(feature = "console_error_panic_hook")] - console_error_panic_hook::set_once(); -} - -pub fn set_logging() { - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) // Only partially supported across browsers - .with_timer(UtcTime::rfc_3339()) // std::time is not available in browsers - .with_writer(MakeConsoleWriter); // write events to the console - let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); - - tracing_subscriber::registry() - .with(fmt_layer) - .with(perf_layer) - .init(); // Install these as subscribers to tracing events -} diff --git a/frontend/player/tsconfig.json b/frontend/player/tsconfig.json new file mode 100644 index 00000000..eaf75b1e --- /dev/null +++ b/frontend/player/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "ES2022", "DOM"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["js", "demo"] +} diff --git a/frontend/player/vite.config.ts b/frontend/player/vite.config.ts new file mode 100644 index 00000000..9606aa22 --- /dev/null +++ b/frontend/player/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vite"; +import path from "path"; +import dts from "vite-plugin-dts"; + +export default defineConfig({ + plugins: [ + dts({ + outputDir: ["dist"], + insertTypesEntry: true, + }), + ], + optimizeDeps: { + exclude: ["player-wasm"], + }, + build: { + minify: false, + target: "esnext", + outDir: "dist", + lib: { + entry: path.resolve(__dirname, "js/main.ts"), + formats: ["es"], + name: "Player", + fileName: "player", + }, + assetsInlineLimit: 0, + rollupOptions: { + external: [/pkg/], + }, + }, +}); diff --git a/frontend/player/vite.demo.config.ts b/frontend/player/vite.demo.config.ts new file mode 100644 index 00000000..5c8cecf8 --- /dev/null +++ b/frontend/player/vite.demo.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [], + build: { + minify: false, + target: "esnext", + outDir: "demo-dist", + assetsInlineLimit: 0, + }, +}); diff --git a/frontend/website/package.json b/frontend/website/package.json index e674fcdc..6723aedd 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -3,20 +3,16 @@ "version": "0.0.1", "private": true, "scripts": { - "dev:pre": "yarn wasm:dev && yarn codegen", - "dev": "yarn dev:pre && vite dev", - "build": "yarn wasm && yarn codegen && NODE_ENV=production vite build", + "dev:pre": "pnpm run codegen", + "dev": "pnpm run dev:pre && vite dev", + "build": "pnpm run codegen && NODE_ENV=production vite build", "preview": "vite preview", "test": "playwright test", "test:unit": "vitest", - "lint": "yarn dev:pre && prettier --plugin-search-dir . --check \"**/*\" -u && eslint . --ext .js,.ts,.svelte && yarn svelte:check", + "lint": "pnpm run dev:pre && prettier --plugin-search-dir . --check \"**/*\" -u && eslint . --ext .js,.ts,.svelte && pnpm run svelte:check", "svelte:check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json | tee /dev/null", "format": "prettier --plugin-search-dir . --write \"**/*\" -u", - "wasm": "mask --maskfile $(realpath ../player/maskfile.md) build", - "wasm:dev": "yarn wasm dev", - "wasm:watch": "yarn wasm:dev --watch", - "wasm:clean": "mask --maskfile $(realpath ../player/maskfile.md) clean", - "clean": "rm -rf build .svelte-kit wasm.d.ts src/gql && yarn wasm:clean", + "clean": "rm -rf build .svelte-kit src/gql", "codegen": "graphql-codegen --config codegen.cjs" }, "devDependencies": { @@ -28,6 +24,9 @@ "@graphql-codegen/typescript-document-nodes": "^3.0.1", "@graphql-typed-document-node/core": "^3.1.2", "@playwright/test": "^1.28.1", + "@rollup/plugin-commonjs": "^24.1.0", + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.0.2", "@rollup/plugin-replace": "^5.0.2", "@sveltejs/kit": "^1.5.0", "@types/fs-extra": "^11.0.1", @@ -42,6 +41,7 @@ "fs-extra": "^11.1.0", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", + "rollup": "^3.21.5", "sass": "^1.58.3", "svelte": "^3.54.0", "svelte-adapter-deno": "^0.9.0", diff --git a/frontend/website/playwright.config.ts b/frontend/website/playwright.config.ts index f9ef1bf9..9608e811 100644 --- a/frontend/website/playwright.config.ts +++ b/frontend/website/playwright.config.ts @@ -3,7 +3,7 @@ import type { PlaywrightTestConfig } from "@playwright/test"; const config: PlaywrightTestConfig = { webServer: { timeout: 10 * 60 * 1000, // 10 minutes we are building WASM and it takes time to compile - command: "yarn build && yarn preview", + command: "pnpm build && pnpm preview", port: 4173, }, testDir: "tests", diff --git a/frontend/website/plugins/wasm.ts b/frontend/website/plugins/wasm.ts deleted file mode 100644 index 91302da7..00000000 --- a/frontend/website/plugins/wasm.ts +++ /dev/null @@ -1,147 +0,0 @@ -import fg from "fast-glob"; -import fs from "fs-extra"; -import type { Plugin } from "vite"; -import { createHash } from "crypto"; -import { resolve, basename } from "path"; - -interface WasmPluginOptions { - name: string; - directory: string; -} - -async function get_files(directory: string) { - return await fg([`${directory}/**/*`]); -} - -const PREFIX = "@wasm@"; - -const typescriptModules = new Map(); - -async function update_module_ts(directory: string, name: string) { - const file = await fs.readFile(`${directory}/pkg/${name}.d.ts`, "utf-8"); - typescriptModules.set(name, file); - - const modules = [...typescriptModules.entries()] - .map(([name, module]) => `declare module "${name}" {\n${module}\n}`) - .join("\n"); - - await fs.writeFile(`./wasm.d.ts`, modules); -} - -export default function wasmPlugin(options: WasmPluginOptions): Plugin { - // Get absolute path to the directory - options.directory = resolve(options.directory); - - const moduleId = options.name; - - let loaded = false; - - let wasmFile = fs.readFileSync(`${options.directory}/pkg/${options.name}_bg.wasm`); - - let isDev = false; - - return { - name: "wasm", - - // This is run on both dev and build mode - apply(_, env) { - if (env.mode === "development") { - isDev = true; - } - - return true; - }, - - // This is run on both dev and build mode - resolveId(id) { - if (id !== moduleId) { - return; - } - - return PREFIX + id; - }, - - // This is run on both dev and build mode - async load(id) { - if (id !== PREFIX + moduleId) { - return; - } - - loaded = true; - - const hash = createHash("sha256").update(wasmFile).digest("hex").slice(0, 8); - return await fs - .readFile(`${options.directory}/pkg/${options.name}.js`, "utf-8") - .then((data) => - data.replaceAll( - `player_bg.wasm`, - isDev ? `${options.name}_bg.wasm` : `assets/${options.name}_${hash}_bg.wasm`, - ), - ); - }, - - // This is run on both dev and build mode - async buildStart() { - const files = await get_files(`${options.directory}/pkg`); - for (const file of files) { - this.addWatchFile(file); - } - - await update_module_ts(options.directory, options.name); - }, - - // This is only run in dev mode - async handleHotUpdate({ file, server }) { - const module = server.moduleGraph.getModuleById(PREFIX + options.name); - - if (!file.startsWith(options.directory) || !module) { - return; - } - - wasmFile = await fs.readFile(`${options.directory}/pkg/${options.name}_bg.wasm`); - await update_module_ts(options.directory, options.name); - - loaded = false; - - await server.reloadModule(module); - }, - - // This is only run in dev mode - configureServer({ middlewares }) { - middlewares.use((req, res, next) => { - if (!req.url) { - next(); - return; - } - - const file = basename(req.url); - if (file === `${options.name}_bg.wasm`) { - res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); - - res.writeHead(200, { "Content-Type": "application/wasm" }); - res.write(wasmFile); - res.end(); - } else { - next(); - } - }); - }, - - // This is only run in build mode - buildEnd() { - if (!loaded) { - return; - } - - const hash = isDev - ? "" - : "_" + createHash("sha256").update(wasmFile).digest("hex").slice(0, 8); - - this.emitFile({ - type: "asset", - fileName: `assets/${options.name}${hash}_bg.wasm`, - source: wasmFile, - }); - }, - }; -} diff --git a/frontend/website/pnpm-lock.yaml b/frontend/website/pnpm-lock.yaml new file mode 100644 index 00000000..cad7c204 --- /dev/null +++ b/frontend/website/pnpm-lock.yaml @@ -0,0 +1,5559 @@ +lockfileVersion: '6.0' + +dependencies: + '@fontsource/be-vietnam-pro': + specifier: ^4.5.8 + version: 4.5.8 + '@fontsource/comfortaa': + specifier: ^4.5.11 + version: 4.5.11 + '@urql/svelte': + specifier: ^3.0.3 + version: 3.0.3(graphql@16.6.0)(svelte@3.54.0) + graphql: + specifier: ^16.6.0 + version: 16.6.0 + graphql-ws: + specifier: ^5.11.3 + version: 5.11.3(graphql@16.6.0) + urql: + specifier: ^3.0.3 + version: 3.0.3(graphql@16.6.0)(react@18.2.0) + wonka: + specifier: ^6.2.3 + version: 6.2.3 + zod: + specifier: ^3.20.6 + version: 3.20.6 + +devDependencies: + '@fortawesome/free-solid-svg-icons': + specifier: ^6.3.0 + version: 6.3.0 + '@graphql-codegen/cli': + specifier: 3.2.1 + version: 3.2.1(@babel/core@7.21.8)(@types/node@18.14.2)(graphql@16.6.0)(typescript@4.9.5) + '@graphql-codegen/client-preset': + specifier: ^2.1.0 + version: 2.1.0(graphql@16.6.0) + '@graphql-codegen/introspection': + specifier: ^3.0.1 + version: 3.0.1(graphql@16.6.0) + '@graphql-codegen/typescript': + specifier: 3.0.1 + version: 3.0.1(graphql@16.6.0) + '@graphql-codegen/typescript-document-nodes': + specifier: ^3.0.1 + version: 3.0.1(graphql@16.6.0) + '@graphql-typed-document-node/core': + specifier: ^3.1.2 + version: 3.1.2(graphql@16.6.0) + '@playwright/test': + specifier: ^1.28.1 + version: 1.28.1 + '@rollup/plugin-commonjs': + specifier: ^24.1.0 + version: 24.1.0(rollup@3.21.5) + '@rollup/plugin-json': + specifier: ^6.0.0 + version: 6.0.0(rollup@3.21.5) + '@rollup/plugin-node-resolve': + specifier: ^15.0.2 + version: 15.0.2(rollup@3.21.5) + '@rollup/plugin-replace': + specifier: ^5.0.2 + version: 5.0.2(rollup@3.21.5) + '@sveltejs/kit': + specifier: ^1.5.0 + version: 1.5.0(svelte@3.54.0)(vite@4.1.1) + '@types/fs-extra': + specifier: ^11.0.1 + version: 11.0.1 + '@types/node': + specifier: ^18.14.2 + version: 18.14.2 + '@typescript-eslint/eslint-plugin': + specifier: ^5.45.0 + version: 5.45.0(@typescript-eslint/parser@5.45.0)(eslint@8.28.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^5.45.0 + version: 5.45.0(eslint@8.28.0)(typescript@4.9.5) + concurrently: + specifier: ^7.6.0 + version: 7.6.0 + eslint: + specifier: ^8.28.0 + version: 8.28.0 + eslint-config-prettier: + specifier: ^8.5.0 + version: 8.5.0(eslint@8.28.0) + eslint-plugin-svelte3: + specifier: ^4.0.0 + version: 4.0.0(eslint@8.28.0)(svelte@3.54.0) + fast-glob: + specifier: ^3.2.12 + version: 3.2.12 + fs-extra: + specifier: ^11.1.0 + version: 11.1.0 + prettier: + specifier: ^2.8.0 + version: 2.8.0 + prettier-plugin-svelte: + specifier: ^2.8.1 + version: 2.8.1(prettier@2.8.0)(svelte@3.54.0) + rollup: + specifier: ^3.21.5 + version: 3.21.5 + sass: + specifier: ^1.58.3 + version: 1.58.3 + svelte: + specifier: ^3.54.0 + version: 3.54.0 + svelte-adapter-deno: + specifier: ^0.9.0 + version: 0.9.0(@sveltejs/kit@1.5.0) + svelte-check: + specifier: ^3.0.1 + version: 3.0.1(@babel/core@7.21.8)(sass@1.58.3)(svelte@3.54.0) + svelte-fa: + specifier: ^3.0.3 + version: 3.0.3 + svelte-turnstile: + specifier: ^0.3.1 + version: 0.3.1 + svelte2tsx: + specifier: ^0.6.2 + version: 0.6.2(svelte@3.54.0)(typescript@4.9.5) + tslib: + specifier: ^2.4.1 + version: 2.4.1 + typescript: + specifier: ^4.9.5 + version: 4.9.5 + vite: + specifier: ^4.1.1 + version: 4.1.1(@types/node@18.14.2)(sass@1.58.3) + vitest: + specifier: ^0.25.3 + version: 0.25.3(sass@1.58.3) + +packages: + + /@ampproject/remapping@2.2.1: + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + dev: true + + /@ardatan/relay-compiler@12.0.0(graphql@16.6.0): + resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} + hasBin: true + peerDependencies: + graphql: '*' + dependencies: + '@babel/core': 7.21.8 + '@babel/generator': 7.21.5 + '@babel/parser': 7.21.8 + '@babel/runtime': 7.21.5 + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + babel-preset-fbjs: 3.4.0(@babel/core@7.21.8) + chalk: 4.1.2 + fb-watchman: 2.0.2 + fbjs: 3.0.4 + glob: 7.2.3 + graphql: 16.6.0 + immutable: 3.7.6 + invariant: 2.2.4 + nullthrows: 1.1.1 + relay-runtime: 12.0.0 + signedsource: 1.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@ardatan/sync-fetch@0.0.1: + resolution: {integrity: sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA==} + engines: {node: '>=14'} + dependencies: + node-fetch: 2.6.9 + transitivePeerDependencies: + - encoding + dev: true + + /@babel/code-frame@7.21.4: + resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + + /@babel/compat-data@7.21.7: + resolution: {integrity: sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.21.8: + resolution: {integrity: sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.5 + '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.21.8) + '@babel/helper-module-transforms': 7.21.5 + '@babel/helpers': 7.21.5 + '@babel/parser': 7.21.8 + '@babel/template': 7.20.7 + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + convert-source-map: 1.9.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.21.5: + resolution: {integrity: sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + jsesc: 2.5.2 + dev: true + + /@babel/helper-annotate-as-pure@7.18.6: + resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-compilation-targets@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.21.7 + '@babel/core': 7.21.8 + '@babel/helper-validator-option': 7.21.0 + browserslist: 4.21.5 + lru-cache: 5.1.1 + semver: 6.3.0 + dev: true + + /@babel/helper-create-class-features-plugin@7.21.8(@babel/core@7.21.8): + resolution: {integrity: sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-function-name': 7.21.0 + '@babel/helper-member-expression-to-functions': 7.21.5 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-replace-supers': 7.21.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 + '@babel/helper-split-export-declaration': 7.18.6 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-environment-visitor@7.21.5: + resolution: {integrity: sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-function-name@7.21.0: + resolution: {integrity: sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.20.7 + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-hoist-variables@7.18.6: + resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-member-expression-to-functions@7.21.5: + resolution: {integrity: sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-module-imports@7.21.4: + resolution: {integrity: sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-module-transforms@7.21.5: + resolution: {integrity: sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-module-imports': 7.21.4 + '@babel/helper-simple-access': 7.21.5 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-validator-identifier': 7.19.1 + '@babel/template': 7.20.7 + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-optimise-call-expression@7.18.6: + resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-plugin-utils@7.21.5: + resolution: {integrity: sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-replace-supers@7.21.5: + resolution: {integrity: sha512-/y7vBgsr9Idu4M6MprbOVUfH3vs7tsIfnVWv/Ml2xgwvyH6LTngdfbf5AdsKwkJy4zgy1X/kuNrEKvhhK28Yrg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-member-expression-to-functions': 7.21.5 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/template': 7.20.7 + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access@7.21.5: + resolution: {integrity: sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-skip-transparent-expression-wrappers@7.20.0: + resolution: {integrity: sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-split-export-declaration@7.18.6: + resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/helper-string-parser@7.21.5: + resolution: {integrity: sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.19.1: + resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.21.0: + resolution: {integrity: sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.21.5: + resolution: {integrity: sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.20.7 + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/highlight@7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.19.1 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + + /@babel/parser@7.21.8: + resolution: {integrity: sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.21.5 + dev: true + + /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-create-class-features-plugin': 7.21.8(@babel/core@7.21.8) + '@babel/helper-plugin-utils': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.21.8): + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.21.7 + '@babel/core': 7.21.8 + '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.21.8) + '@babel/helper-plugin-utils': 7.21.5 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-transform-parameters': 7.21.3(@babel/core@7.21.8) + dev: true + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.21.8): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-syntax-flow@7.21.4(@babel/core@7.21.8): + resolution: {integrity: sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-syntax-import-assertions@7.20.0(@babel/core@7.21.8): + resolution: {integrity: sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.21.8): + resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-arrow-functions@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-block-scoped-functions@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-block-scoping@7.21.0(@babel/core@7.21.8): + resolution: {integrity: sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-classes@7.21.0(@babel/core@7.21.8): + resolution: {integrity: sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.21.8) + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-function-name': 7.21.0 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/helper-replace-supers': 7.21.5 + '@babel/helper-split-export-declaration': 7.18.6 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-computed-properties@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/template': 7.20.7 + dev: true + + /@babel/plugin-transform-destructuring@7.21.3(@babel/core@7.21.8): + resolution: {integrity: sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-flow-strip-types@7.21.0(@babel/core@7.21.8): + resolution: {integrity: sha512-FlFA2Mj87a6sDkW4gfGrQQqwY/dLlBAyJa2dJEZ+FHXUVHBflO2wyKvg+OOEzXfrKYIa4HWl0mgmbCzt0cMb7w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/plugin-syntax-flow': 7.21.4(@babel/core@7.21.8) + dev: true + + /@babel/plugin-transform-for-of@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-function-name@7.18.9(@babel/core@7.21.8): + resolution: {integrity: sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.21.8) + '@babel/helper-function-name': 7.21.0 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-literals@7.18.9(@babel/core@7.21.8): + resolution: {integrity: sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-member-expression-literals@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-modules-commonjs@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-module-transforms': 7.21.5 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/helper-simple-access': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-object-super@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/helper-replace-supers': 7.21.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-parameters@7.21.3(@babel/core@7.21.8): + resolution: {integrity: sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-property-literals@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-react-jsx@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-ELdlq61FpoEkHO6gFRpfj0kUgSwQTGoaEU8eMRoS8Dv3v6e7BjEAj5WMtIBRdHUeAioMhKP5HyxNzNnP+heKbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-module-imports': 7.21.4 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.21.8) + '@babel/types': 7.21.5 + dev: true + + /@babel/plugin-transform-shorthand-properties@7.18.6(@babel/core@7.21.8): + resolution: {integrity: sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-spread@7.20.7(@babel/core@7.21.8): + resolution: {integrity: sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 + dev: true + + /@babel/plugin-transform-template-literals@7.18.9(@babel/core@7.21.8): + resolution: {integrity: sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/runtime@7.21.5: + resolution: {integrity: sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.11 + dev: true + + /@babel/template@7.20.7: + resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.21.4 + '@babel/parser': 7.21.8 + '@babel/types': 7.21.5 + dev: true + + /@babel/traverse@7.21.5: + resolution: {integrity: sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.5 + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-function-name': 7.21.0 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.21.8 + '@babel/types': 7.21.5 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types@7.21.5: + resolution: {integrity: sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.21.5 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + dev: true + + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@esbuild/android-arm64@0.16.17: + resolution: {integrity: sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.15.18: + resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.16.17: + resolution: {integrity: sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.16.17: + resolution: {integrity: sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.16.17: + resolution: {integrity: sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.16.17: + resolution: {integrity: sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.16.17: + resolution: {integrity: sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.16.17: + resolution: {integrity: sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.16.17: + resolution: {integrity: sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.16.17: + resolution: {integrity: sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.16.17: + resolution: {integrity: sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.15.18: + resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.16.17: + resolution: {integrity: sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.16.17: + resolution: {integrity: sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.16.17: + resolution: {integrity: sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.16.17: + resolution: {integrity: sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.16.17: + resolution: {integrity: sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.16.17: + resolution: {integrity: sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.16.17: + resolution: {integrity: sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.16.17: + resolution: {integrity: sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.16.17: + resolution: {integrity: sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.16.17: + resolution: {integrity: sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.16.17: + resolution: {integrity: sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.16.17: + resolution: {integrity: sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint/eslintrc@1.4.1: + resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.5.2 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@fontsource/be-vietnam-pro@4.5.8: + resolution: {integrity: sha512-02BI3zS+7Rs4vffa2U6AznQEjG2skMn4nswjEzwQc7uzE04n1MLxh9Mhf3KT5GBuP9HuPHJYVgytcGQ1xNLJfw==} + dev: false + + /@fontsource/comfortaa@4.5.11: + resolution: {integrity: sha512-KKC2C6KbF9BD6m9+wMf5hK0wFjIi3p3J/6C4JZW6OF9G6K4qZJFp2dBZzsEBepKh4s9/Q5G1SWsUUZY3ZeZNDA==} + dev: false + + /@fortawesome/fontawesome-common-types@6.3.0: + resolution: {integrity: sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==} + engines: {node: '>=6'} + requiresBuild: true + dev: true + + /@fortawesome/free-solid-svg-icons@6.3.0: + resolution: {integrity: sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==} + engines: {node: '>=6'} + requiresBuild: true + dependencies: + '@fortawesome/fontawesome-common-types': 6.3.0 + dev: true + + /@graphql-codegen/add@4.0.1(graphql@16.6.0): + resolution: {integrity: sha512-A7k+9eRfrKyyNfhWEN/0eKz09R5cp4XXxUuNLQAVm/aohmVI2xdMV4lM02rTlM6Pyou3cU/v0iZnhgo6IRpqeg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + dev: true + + /@graphql-codegen/cli@3.2.1(@babel/core@7.21.8)(@types/node@18.14.2)(graphql@16.6.0)(typescript@4.9.5): + resolution: {integrity: sha512-AeXzOvhSgAyMC0TzIoc6/HIc2Fy2rCZJcs5pt1LDypn1k4gpGRzqZ5JOjYx+XIna2hLfB9NbAkcO5dcdHwFdJA==} + hasBin: true + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@babel/generator': 7.21.5 + '@babel/template': 7.20.7 + '@babel/types': 7.21.5 + '@graphql-codegen/core': 3.1.0(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/apollo-engine-loader': 7.3.26(graphql@16.6.0) + '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.21.8)(graphql@16.6.0) + '@graphql-tools/git-loader': 7.2.22(@babel/core@7.21.8)(graphql@16.6.0) + '@graphql-tools/github-loader': 7.3.28(@babel/core@7.21.8)(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.6.0) + '@graphql-tools/json-file-loader': 7.4.18(graphql@16.6.0) + '@graphql-tools/load': 7.8.14(graphql@16.6.0) + '@graphql-tools/prisma-loader': 7.2.71(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@parcel/watcher': 2.1.0 + '@whatwg-node/fetch': 0.8.8 + chalk: 4.1.2 + cosmiconfig: 7.1.0 + cosmiconfig-typescript-loader: 4.3.0(@types/node@18.14.2)(cosmiconfig@7.1.0)(ts-node@10.9.1)(typescript@4.9.5) + debounce: 1.2.1 + detect-indent: 6.1.0 + graphql: 16.6.0 + graphql-config: 4.5.0(@types/node@18.14.2)(graphql@16.6.0) + inquirer: 8.2.5 + is-glob: 4.0.3 + json-to-pretty-yaml: 1.2.2 + listr2: 4.0.5 + log-symbols: 4.1.0 + micromatch: 4.0.5 + shell-quote: 1.8.1 + string-env-interpolation: 1.0.1 + ts-log: 2.2.5 + ts-node: 10.9.1(@types/node@18.14.2)(typescript@4.9.5) + tslib: 2.4.1 + yaml: 1.10.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@babel/core' + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - enquirer + - supports-color + - typescript + - utf-8-validate + dev: true + + /@graphql-codegen/client-preset@2.1.0(graphql@16.6.0): + resolution: {integrity: sha512-mt5CyPwZmOUP+ifC56xMjeEyfywu0P6HSWbhWPn1Jbv7n3TMILXMDfgOAufnOmrU1Ian8wu72I9A5IMRGqmW1w==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@babel/helper-plugin-utils': 7.21.5 + '@babel/template': 7.20.7 + '@graphql-codegen/add': 4.0.1(graphql@16.6.0) + '@graphql-codegen/gql-tag-operations': 2.0.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/typed-document-node': 3.0.2(graphql@16.6.0) + '@graphql-codegen/typescript': 3.0.1(graphql@16.6.0) + '@graphql-codegen/typescript-operations': 3.0.4(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + '@graphql-tools/documents': 0.1.0(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-typed-document-node/core': 3.1.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/core@3.1.0(graphql@16.6.0): + resolution: {integrity: sha512-DH1/yaR7oJE6/B+c6ZF2Tbdh7LixF1K8L+8BoSubjNyQ8pNwR4a70mvc1sv6H7qgp6y1bPQ9tKE+aazRRshysw==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/schema': 9.0.19(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + dev: true + + /@graphql-codegen/gql-tag-operations@2.0.1(graphql@16.6.0): + resolution: {integrity: sha512-BGJRfRYJo566x3nPoEwiU0KkhbBAB2i4UsUg2wAlzC+z8uoL1JtCI2besa7RoWxjvEpmjrn23O5CnUzD933JLg==} + deprecated: 'This module has been deprecated. Please use client-preset instead. See: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client' + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.0.1(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + auto-bind: 4.0.0 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/introspection@3.0.1(graphql@16.6.0): + resolution: {integrity: sha512-D6vJQTEL/np4EmeUHm5spLK59cr+AMXEoLRoTI+dagFzlHYDTfXZH6F7uhKaakxcj0SAQhIWKvGMggotUdEtyg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/plugin-helpers@4.2.0(graphql@16.6.0): + resolution: {integrity: sha512-THFTCfg+46PXlXobYJ/OoCX6pzjI+9woQqCjdyKtgoI0tn3Xq2HUUCiidndxUpEYVrXb5pRiRXb7b/ZbMQqD0A==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.6.0 + import-from: 4.0.0 + lodash: 4.17.21 + tslib: 2.5.0 + dev: true + + /@graphql-codegen/schema-ast@3.0.1(graphql@16.6.0): + resolution: {integrity: sha512-rTKTi4XiW4QFZnrEqetpiYEWVsOFNoiR/v3rY9mFSttXFbIwNXPme32EspTiGWmEEdHY8UuTDtZN3vEcs/31zw==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + dev: true + + /@graphql-codegen/typed-document-node@3.0.2(graphql@16.6.0): + resolution: {integrity: sha512-RqX46y0GoMAcCfXjkUabOWpeSQ7tazpS5WyzWJNakpzXxNACx8NACaghU8zTEM+gjqtIp6YbFY/S92HQ34HbRQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.0.2(graphql@16.6.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/typescript-document-nodes@3.0.1(graphql@16.6.0): + resolution: {integrity: sha512-5bTy5BJ8Ci+Pg9k5JscsjbE3rZalClLDntkL8sxfYSbW6Qth4ySG5D7wx5eBfKAVgqEkhKHNtwz6SsQ5CSys8A==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.0.1(graphql@16.6.0) + auto-bind: 4.0.0 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/typescript-operations@3.0.4(graphql@16.6.0): + resolution: {integrity: sha512-6yE2OL2+WJ1vd5MwFEGXpaxsFGzjAGUytPVHDML3Bi3TwP1F3lnQlIko4untwvHW0JhZEGQ7Ck30H9HjcxpdKA==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/typescript': 3.0.4(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + auto-bind: 4.0.0 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/typescript@3.0.1(graphql@16.6.0): + resolution: {integrity: sha512-HvozJg7eHqywmYvXa7+nmjw+v3+f8ilFv9VbRvmjhj/zBw3VKGT2n/85ZhVyuWjY2KrDLzl6BqeXttWsW5Wo4w==} + peerDependencies: + graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/schema-ast': 3.0.1(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.0.1(graphql@16.6.0) + auto-bind: 4.0.0 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/typescript@3.0.4(graphql@16.6.0): + resolution: {integrity: sha512-x4O47447DZrWNtE/l5CU9QzzW4m1RbmCEdijlA3s2flG/y1Ckqdemob4CWfilSm5/tZ3w1junVDY616RDTSvZw==} + peerDependencies: + graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-codegen/schema-ast': 3.0.1(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + auto-bind: 4.0.0 + graphql: 16.6.0 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/visitor-plugin-common@3.0.1(graphql@16.6.0): + resolution: {integrity: sha512-Qek+Ywy094Km7Vc1TzKBN9ICvtYwPdqZUliPO77urMSveP+2+G2O9Tjx546dW4A1O6rhEfexbenc2DqTAe7iLQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.6.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.6.0 + graphql-tag: 2.12.6(graphql@16.6.0) + parse-filepath: 1.0.2 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/visitor-plugin-common@3.0.2(graphql@16.6.0): + resolution: {integrity: sha512-dKblRFrB0Fdl3+nPlzlLBka+TN/EGwr/q09mwry0H58z3j6gXkMbsdPr+dc8MhgOV7w/8egRvSPIvd7m6eFCnw==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.6.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.6.0 + graphql-tag: 2.12.6(graphql@16.6.0) + parse-filepath: 1.0.2 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-codegen/visitor-plugin-common@3.1.1(graphql@16.6.0): + resolution: {integrity: sha512-uAfp+zu/009R3HUAuTK2AamR1bxIltM6rrYYI6EXSmkM3rFtFsLTuJhjUDj98HcUCszJZrADppz8KKLGRUVlNg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.6.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.6.0 + graphql-tag: 2.12.6(graphql@16.6.0) + parse-filepath: 1.0.2 + tslib: 2.5.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-tools/apollo-engine-loader@7.3.26(graphql@16.6.0): + resolution: {integrity: sha512-h1vfhdJFjnCYn9b5EY1Z91JTF0KB3hHVJNQIsiUV2mpQXZdeOXQoaWeYEKaiI5R6kwBw5PP9B0fv3jfUIG8LyQ==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/sync-fetch': 0.0.1 + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@whatwg-node/fetch': 0.8.8 + graphql: 16.6.0 + tslib: 2.4.1 + transitivePeerDependencies: + - encoding + dev: true + + /@graphql-tools/batch-execute@8.5.22(graphql@16.6.0): + resolution: {integrity: sha512-hcV1JaY6NJQFQEwCKrYhpfLK8frSXDbtNMoTur98u10Cmecy1zrqNKSqhEyGetpgHxaJRqszGzKeI3RuroDN6A==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + dataloader: 2.2.2 + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.12 + dev: true + + /@graphql-tools/code-file-loader@7.3.23(@babel/core@7.21.8)(graphql@16.6.0): + resolution: {integrity: sha512-8Wt1rTtyTEs0p47uzsPJ1vAtfAx0jmxPifiNdmo9EOCuUPyQGEbMaik/YkqZ7QUFIEYEQu+Vgfo8tElwOPtx5Q==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.21.8)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + globby: 11.1.0 + graphql: 16.6.0 + tslib: 2.4.1 + unixify: 1.0.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: true + + /@graphql-tools/delegate@9.0.35(graphql@16.6.0): + resolution: {integrity: sha512-jwPu8NJbzRRMqi4Vp/5QX1vIUeUPpWmlQpOkXQD2r1X45YsVceyUUBnktCrlJlDB4jPRVy7JQGwmYo3KFiOBMA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/batch-execute': 8.5.22(graphql@16.6.0) + '@graphql-tools/executor': 0.0.20(graphql@16.6.0) + '@graphql-tools/schema': 9.0.19(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + dataloader: 2.2.2 + graphql: 16.6.0 + tslib: 2.5.0 + value-or-promise: 1.0.12 + dev: true + + /@graphql-tools/documents@0.1.0(graphql@16.6.0): + resolution: {integrity: sha512-1WQeovHv5S1M3xMzQxbSrG3yl6QOnsq2JUBnlg5/0aMM5R4GNMx6Ms+ROByez/dnuA81kstRuSK+2qpe+GaRIw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.6.0 + lodash.sortby: 4.7.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/executor-graphql-ws@0.0.14(graphql@16.6.0): + resolution: {integrity: sha512-P2nlkAsPZKLIXImFhj0YTtny5NQVGSsKnhi7PzXiaHSXc6KkzqbWZHKvikD4PObanqg+7IO58rKFpGXP7eeO+w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@repeaterjs/repeater': 3.0.4 + '@types/ws': 8.5.4 + graphql: 16.6.0 + graphql-ws: 5.12.1(graphql@16.6.0) + isomorphic-ws: 5.0.0(ws@8.13.0) + tslib: 2.4.1 + ws: 8.13.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /@graphql-tools/executor-http@0.1.9(@types/node@18.14.2)(graphql@16.6.0): + resolution: {integrity: sha512-tNzMt5qc1ptlHKfpSv9wVBVKCZ7gks6Yb/JcYJluxZIT4qRV+TtOFjpptfBU63usgrGVOVcGjzWc/mt7KhmmpQ==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@repeaterjs/repeater': 3.0.4 + '@whatwg-node/fetch': 0.8.8 + dset: 3.1.2 + extract-files: 11.0.0 + graphql: 16.6.0 + meros: 1.2.1(@types/node@18.14.2) + tslib: 2.4.1 + value-or-promise: 1.0.12 + transitivePeerDependencies: + - '@types/node' + dev: true + + /@graphql-tools/executor-legacy-ws@0.0.11(graphql@16.6.0): + resolution: {integrity: sha512-4ai+NnxlNfvIQ4c70hWFvOZlSUN8lt7yc+ZsrwtNFbFPH/EroIzFMapAxM9zwyv9bH38AdO3TQxZ5zNxgBdvUw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@types/ws': 8.5.4 + graphql: 16.6.0 + isomorphic-ws: 5.0.0(ws@8.13.0) + tslib: 2.4.1 + ws: 8.13.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /@graphql-tools/executor@0.0.20(graphql@16.6.0): + resolution: {integrity: sha512-GdvNc4vszmfeGvUqlcaH1FjBoguvMYzxAfT6tDd4/LgwymepHhinqLNA5otqwVLW+JETcDaK7xGENzFomuE6TA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) + '@repeaterjs/repeater': 3.0.4 + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.12 + dev: true + + /@graphql-tools/git-loader@7.2.22(@babel/core@7.21.8)(graphql@16.6.0): + resolution: {integrity: sha512-9rpHggHiOeqA7/ZlKD3c5yXk5bPGw0zkIgKMerjCmFAQAZ6CEVfsa7nAzEWQxn6rpdaBft4/0A56rPMrsUwGBA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.21.8)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + is-glob: 4.0.3 + micromatch: 4.0.5 + tslib: 2.4.1 + unixify: 1.0.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: true + + /@graphql-tools/github-loader@7.3.28(@babel/core@7.21.8)(@types/node@18.14.2)(graphql@16.6.0): + resolution: {integrity: sha512-OK92Lf9pmxPQvjUNv05b3tnVhw0JRfPqOf15jZjyQ8BfdEUrJoP32b4dRQQem/wyRL24KY4wOfArJNqzpsbwCA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/sync-fetch': 0.0.1 + '@graphql-tools/executor-http': 0.1.9(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.21.8)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@whatwg-node/fetch': 0.8.8 + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.12 + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - encoding + - supports-color + dev: true + + /@graphql-tools/graphql-file-loader@7.5.17(graphql@16.6.0): + resolution: {integrity: sha512-hVwwxPf41zOYgm4gdaZILCYnKB9Zap7Ys9OhY1hbwuAuC4MMNY9GpUjoTU3CQc3zUiPoYStyRtUGkHSJZ3HxBw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/import': 6.7.18(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + globby: 11.1.0 + graphql: 16.6.0 + tslib: 2.4.1 + unixify: 1.0.0 + dev: true + + /@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.21.8)(graphql@16.6.0): + resolution: {integrity: sha512-RW+H8FqOOLQw0BPXaahYepVSRjuOHw+7IL8Opaa5G5uYGOBxoXR7DceyQ7BcpMgktAOOmpDNQ2WtcboChOJSRA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/parser': 7.21.8 + '@babel/plugin-syntax-import-assertions': 7.20.0(@babel/core@7.21.8) + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: true + + /@graphql-tools/import@6.7.18(graphql@16.6.0): + resolution: {integrity: sha512-XQDdyZTp+FYmT7as3xRWH/x8dx0QZA2WZqfMF5EWb36a0PiH7WwlRQYIdyYXj8YCLpiWkeBXgBRHmMnwEYR8iQ==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + resolve-from: 5.0.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/json-file-loader@7.4.18(graphql@16.6.0): + resolution: {integrity: sha512-AJ1b6Y1wiVgkwsxT5dELXhIVUPs/u3VZ8/0/oOtpcoyO/vAeM5rOvvWegzicOOnQw8G45fgBRMkkRfeuwVt6+w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + globby: 11.1.0 + graphql: 16.6.0 + tslib: 2.4.1 + unixify: 1.0.0 + dev: true + + /@graphql-tools/load@7.8.14(graphql@16.6.0): + resolution: {integrity: sha512-ASQvP+snHMYm+FhIaLxxFgVdRaM0vrN9wW2BKInQpktwWTXVyk+yP5nQUCEGmn0RTdlPKrffBaigxepkEAJPrg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/schema': 9.0.19(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + p-limit: 3.1.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/merge@8.4.1(graphql@16.6.0): + resolution: {integrity: sha512-hssnPpZ818mxgl5+GfyOOSnnflAxiaTn1A1AojZcIbh4J52sS1Q0gSuBR5VrnUDjuxiqoCotpXdAQl+K+U6KLQ==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/optimize@1.4.0(graphql@16.6.0): + resolution: {integrity: sha512-dJs/2XvZp+wgHH8T5J2TqptT9/6uVzIYvA6uFACha+ufvdMBedkfR4b4GbT8jAKLRARiqRTxy3dctnwkTM2tdw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.6.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/prisma-loader@7.2.71(@types/node@18.14.2)(graphql@16.6.0): + resolution: {integrity: sha512-FuIvhRrkduqPdj3QX0/anCxGViEETfoZ/1NvotfM6iVO1XxR75VXvP/iyKGbK6XvYRXwSstgj2DetlQnqdgXhA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/url-loader': 7.17.18(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@types/js-yaml': 4.0.5 + '@types/json-stable-stringify': 1.0.34 + '@whatwg-node/fetch': 0.8.8 + chalk: 4.1.2 + debug: 4.3.4 + dotenv: 16.0.3 + graphql: 16.6.0 + graphql-request: 6.0.0(graphql@16.6.0) + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + jose: 4.14.4 + js-yaml: 4.1.0 + json-stable-stringify: 1.0.2 + lodash: 4.17.21 + scuid: 1.1.0 + tslib: 2.4.1 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@graphql-tools/relay-operation-optimizer@6.5.18(graphql@16.6.0): + resolution: {integrity: sha512-mc5VPyTeV+LwiM+DNvoDQfPqwQYhPV/cl5jOBjTgSniyaq8/86aODfMkrE2OduhQ5E00hqrkuL2Fdrgk0w1QJg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/relay-compiler': 12.0.0(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@graphql-tools/schema@9.0.19(graphql@16.6.0): + resolution: {integrity: sha512-oBRPoNBtCkk0zbUsyP4GaIzCt8C0aCI4ycIRUL67KK5pOHljKLBBtGT+Jr6hkzA74C8Gco8bpZPe7aWFjiaK2w==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/merge': 8.4.1(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.12 + dev: true + + /@graphql-tools/url-loader@7.17.18(@types/node@18.14.2)(graphql@16.6.0): + resolution: {integrity: sha512-ear0CiyTj04jCVAxi7TvgbnGDIN2HgqzXzwsfcqiVg9cvjT40NcMlZ2P1lZDgqMkZ9oyLTV8Bw6j+SyG6A+xPw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/sync-fetch': 0.0.1 + '@graphql-tools/delegate': 9.0.35(graphql@16.6.0) + '@graphql-tools/executor-graphql-ws': 0.0.14(graphql@16.6.0) + '@graphql-tools/executor-http': 0.1.9(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/executor-legacy-ws': 0.0.11(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-tools/wrap': 9.4.2(graphql@16.6.0) + '@types/ws': 8.5.4 + '@whatwg-node/fetch': 0.8.8 + graphql: 16.6.0 + isomorphic-ws: 5.0.0(ws@8.13.0) + tslib: 2.4.1 + value-or-promise: 1.0.12 + ws: 8.13.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - utf-8-validate + dev: true + + /@graphql-tools/utils@9.2.1(graphql@16.6.0): + resolution: {integrity: sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-typed-document-node/core': 3.1.2(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + dev: true + + /@graphql-tools/wrap@9.4.2(graphql@16.6.0): + resolution: {integrity: sha512-DFcd9r51lmcEKn0JW43CWkkI2D6T9XI1juW/Yo86i04v43O9w2/k4/nx2XTJv4Yv+iXwUw7Ok81PGltwGJSDSA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/delegate': 9.0.35(graphql@16.6.0) + '@graphql-tools/schema': 9.0.19(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.4.1 + value-or-promise: 1.0.12 + dev: true + + /@graphql-typed-document-node/core@3.1.1(graphql@16.6.0): + resolution: {integrity: sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + graphql: 16.6.0 + dev: true + + /@graphql-typed-document-node/core@3.1.2(graphql@16.6.0): + resolution: {integrity: sha512-9anpBMM9mEgZN4wr2v8wHJI2/u5TnnggewRN6OlvXTTnuVyoY19X6rOv9XTqKRw6dcGKwZsBi8n0kDE2I5i4VA==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.6.0 + dev: true + + /@graphql-typed-document-node/core@3.2.0(graphql@16.6.0): + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.6.0 + dev: true + + /@humanwhocodes/config-array@0.11.8: + resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@jridgewell/gen-mapping@0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.18 + dev: true + + /@jridgewell/resolve-uri@3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array@1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@jridgewell/trace-mapping@0.3.18: + resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + dev: true + + /@parcel/watcher@2.1.0: + resolution: {integrity: sha512-8s8yYjd19pDSsBpbkOHnT6Z2+UJSuLQx61pCFM0s5wSRvKCEMDjd/cHY3/GI1szHIWbpXpsJdg3V6ISGGx9xDw==} + engines: {node: '>= 10.0.0'} + requiresBuild: true + dependencies: + is-glob: 4.0.3 + micromatch: 4.0.5 + node-addon-api: 3.2.1 + node-gyp-build: 4.6.0 + dev: true + + /@peculiar/asn1-schema@2.3.6: + resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==} + dependencies: + asn1js: 3.0.5 + pvtsutils: 1.3.2 + tslib: 2.4.1 + dev: true + + /@peculiar/json-schema@1.1.12: + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + dependencies: + tslib: 2.4.1 + dev: true + + /@peculiar/webcrypto@1.4.3: + resolution: {integrity: sha512-VtaY4spKTdN5LjJ04im/d/joXuvLbQdgy5Z4DXF4MFZhQ+MTrejbNMkfZBp1Bs3O5+bFqnJgyGdPuZQflvIa5A==} + engines: {node: '>=10.12.0'} + dependencies: + '@peculiar/asn1-schema': 2.3.6 + '@peculiar/json-schema': 1.1.12 + pvtsutils: 1.3.2 + tslib: 2.5.0 + webcrypto-core: 1.7.7 + dev: true + + /@playwright/test@1.28.1: + resolution: {integrity: sha512-xN6spdqrNlwSn9KabIhqfZR7IWjPpFK1835tFNgjrlysaSezuX8PYUwaz38V/yI8TJLG9PkAMEXoHRXYXlpTPQ==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@types/node': 18.14.2 + playwright-core: 1.28.1 + dev: true + + /@polka/url@1.0.0-next.21: + resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} + dev: true + + /@repeaterjs/repeater@3.0.4: + resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} + dev: true + + /@rollup/plugin-commonjs@24.1.0(rollup@3.21.5): + resolution: {integrity: sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.21.5) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.27.0 + rollup: 3.21.5 + dev: true + + /@rollup/plugin-json@6.0.0(rollup@3.21.5): + resolution: {integrity: sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.21.5) + rollup: 3.21.5 + dev: true + + /@rollup/plugin-node-resolve@15.0.2(rollup@3.21.5): + resolution: {integrity: sha512-Y35fRGUjC3FaurG722uhUuG8YHOJRJQbI6/CkbRkdPotSpDj9NtIN85z1zrcyDcCQIW4qp5mgG72U+gJ0TAFEg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.21.5) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-builtin-module: 3.2.1 + is-module: 1.0.0 + resolve: 1.22.2 + rollup: 3.21.5 + dev: true + + /@rollup/plugin-replace@5.0.2(rollup@3.21.5): + resolution: {integrity: sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.21.5) + magic-string: 0.27.0 + rollup: 3.21.5 + dev: true + + /@rollup/pluginutils@5.0.2(rollup@3.21.5): + resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.1 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 3.21.5 + dev: true + + /@sveltejs/kit@1.5.0(svelte@3.54.0)(vite@4.1.1): + resolution: {integrity: sha512-AkWgCO9i2djZjTqCgIQJ5XfnSzRINowh2w2Gk9wDRuTwxKizSuYe3jNvds/HCDDGHo8XE5E0yWNC9j2XxbrX+g==} + engines: {node: ^16.14 || >=18} + hasBin: true + requiresBuild: true + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + '@sveltejs/vite-plugin-svelte': 2.2.0(svelte@3.54.0)(vite@4.1.1) + '@types/cookie': 0.5.1 + cookie: 0.5.0 + devalue: 4.3.0 + esm-env: 1.0.0 + kleur: 4.1.5 + magic-string: 0.27.0 + mime: 3.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.3 + svelte: 3.54.0 + tiny-glob: 0.2.9 + undici: 5.18.0 + vite: 4.1.1(@types/node@18.14.2)(sass@1.58.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@sveltejs/vite-plugin-svelte@2.2.0(svelte@3.54.0)(vite@4.1.1): + resolution: {integrity: sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.0 + svelte: 3.54.0 + svelte-hmr: 0.15.1(svelte@3.54.0) + vite: 4.1.1(@types/node@18.14.2)(sass@1.58.3) + vitefu: 0.2.4(vite@4.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@tsconfig/node10@1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.3: + resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} + dev: true + + /@types/chai-subset@1.3.3: + resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} + dependencies: + '@types/chai': 4.3.5 + dev: true + + /@types/chai@4.3.5: + resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} + dev: true + + /@types/cookie@0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} + dev: true + + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + + /@types/fs-extra@11.0.1: + resolution: {integrity: sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==} + dependencies: + '@types/jsonfile': 6.1.1 + '@types/node': 18.14.2 + dev: true + + /@types/js-yaml@4.0.5: + resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} + dev: true + + /@types/json-schema@7.0.11: + resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + dev: true + + /@types/json-stable-stringify@1.0.34: + resolution: {integrity: sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==} + dev: true + + /@types/jsonfile@6.1.1: + resolution: {integrity: sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==} + dependencies: + '@types/node': 18.14.2 + dev: true + + /@types/node@18.14.2: + resolution: {integrity: sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==} + dev: true + + /@types/parse-json@4.0.0: + resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + dev: true + + /@types/pug@2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/resolve@1.20.2: + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + dev: true + + /@types/semver@7.3.13: + resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} + dev: true + + /@types/ws@8.5.4: + resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + dependencies: + '@types/node': 18.14.2 + dev: true + + /@typescript-eslint/eslint-plugin@5.45.0(@typescript-eslint/parser@5.45.0)(eslint@8.28.0)(typescript@4.9.5): + resolution: {integrity: sha512-CXXHNlf0oL+Yg021cxgOdMHNTXD17rHkq7iW6RFHoybdFgQBjU3yIXhhcPpGwr1CjZlo6ET8C6tzX5juQoXeGA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/parser': 5.45.0(eslint@8.28.0)(typescript@4.9.5) + '@typescript-eslint/scope-manager': 5.45.0 + '@typescript-eslint/type-utils': 5.45.0(eslint@8.28.0)(typescript@4.9.5) + '@typescript-eslint/utils': 5.45.0(eslint@8.28.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.28.0 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + regexpp: 3.2.0 + semver: 7.5.0 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@5.45.0(eslint@8.28.0)(typescript@4.9.5): + resolution: {integrity: sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.45.0 + '@typescript-eslint/types': 5.45.0 + '@typescript-eslint/typescript-estree': 5.45.0(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.28.0 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.45.0: + resolution: {integrity: sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.45.0 + '@typescript-eslint/visitor-keys': 5.45.0 + dev: true + + /@typescript-eslint/type-utils@5.45.0(eslint@8.28.0)(typescript@4.9.5): + resolution: {integrity: sha512-DY7BXVFSIGRGFZ574hTEyLPRiQIvI/9oGcN8t1A7f6zIs6ftbrU0nhyV26ZW//6f85avkwrLag424n+fkuoJ1Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.45.0(typescript@4.9.5) + '@typescript-eslint/utils': 5.45.0(eslint@8.28.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.28.0 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.45.0: + resolution: {integrity: sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.45.0(typescript@4.9.5): + resolution: {integrity: sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.45.0 + '@typescript-eslint/visitor-keys': 5.45.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.5.0 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.45.0(eslint@8.28.0)(typescript@4.9.5): + resolution: {integrity: sha512-OUg2JvsVI1oIee/SwiejTot2OxwU8a7UfTFMOdlhD2y+Hl6memUSL4s98bpUTo8EpVEr0lmwlU7JSu/p2QpSvA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@types/json-schema': 7.0.11 + '@types/semver': 7.3.13 + '@typescript-eslint/scope-manager': 5.45.0 + '@typescript-eslint/types': 5.45.0 + '@typescript-eslint/typescript-estree': 5.45.0(typescript@4.9.5) + eslint: 8.28.0 + eslint-scope: 5.1.1 + eslint-utils: 3.0.0(eslint@8.28.0) + semver: 7.5.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.45.0: + resolution: {integrity: sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.45.0 + eslint-visitor-keys: 3.4.1 + dev: true + + /@urql/core@3.2.2(graphql@16.6.0): + resolution: {integrity: sha512-i046Cz8cZ4xIzGMTyHZrbdgzcFMcKD7+yhCAH5FwWBRjcKrc+RjEOuR9X5AMuBvr8c6IAaE92xAqa4wmlGfWTQ==} + peerDependencies: + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + graphql: 16.6.0 + wonka: 6.2.3 + dev: false + + /@urql/svelte@3.0.3(graphql@16.6.0)(svelte@3.54.0): + resolution: {integrity: sha512-/vdiEdCik/7PI0HXtE/e8SaCI/2LiotYMLuVw7k4APmwpBgfeD1gMfTwm5W2EqtH7M4SOjoSERZD8kf2JsP90A==} + peerDependencies: + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + svelte: ^3.0.0 + dependencies: + '@urql/core': 3.2.2(graphql@16.6.0) + graphql: 16.6.0 + svelte: 3.54.0 + wonka: 6.2.3 + dev: false + + /@whatwg-node/events@0.0.3: + resolution: {integrity: sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==} + dev: true + + /@whatwg-node/fetch@0.8.8: + resolution: {integrity: sha512-CdcjGC2vdKhc13KKxgsc6/616BQ7ooDIgPeTuAiE8qfCnS0mGzcfCOoZXypQSz73nxI+GWc7ZReIAVhxoE1KCg==} + dependencies: + '@peculiar/webcrypto': 1.4.3 + '@whatwg-node/node-fetch': 0.3.6 + busboy: 1.6.0 + urlpattern-polyfill: 8.0.2 + web-streams-polyfill: 3.2.1 + dev: true + + /@whatwg-node/node-fetch@0.3.6: + resolution: {integrity: sha512-w9wKgDO4C95qnXZRwZTfCmLWqyRnooGjcIwG0wADWjw9/HN0p7dtvtgSvItZtUyNteEvgTrd8QojNEqV6DAGTA==} + dependencies: + '@whatwg-node/events': 0.0.3 + busboy: 1.6.0 + fast-querystring: 1.1.1 + fast-url-parser: 1.1.3 + tslib: 2.4.1 + dev: true + + /acorn-jsx@5.3.2(acorn@8.8.2): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.8.2 + dev: true + + /acorn-walk@8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@8.8.2: + resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + dev: true + + /asn1js@3.0.5: + resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} + engines: {node: '>=12.0.0'} + dependencies: + pvtsutils: 1.3.2 + pvutils: 1.1.3 + tslib: 2.4.1 + dev: true + + /assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + + /astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + dev: true + + /auto-bind@4.0.0: + resolution: {integrity: sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==} + engines: {node: '>=8'} + dev: true + + /babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: + resolution: {integrity: sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==} + dev: true + + /babel-preset-fbjs@3.4.0(@babel/core@7.21.8): + resolution: {integrity: sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.21.8 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.21.8) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.21.8) + '@babel/plugin-syntax-flow': 7.21.4(@babel/core@7.21.8) + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.21.8) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-transform-arrow-functions': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-transform-block-scoped-functions': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-block-scoping': 7.21.0(@babel/core@7.21.8) + '@babel/plugin-transform-classes': 7.21.0(@babel/core@7.21.8) + '@babel/plugin-transform-computed-properties': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-transform-destructuring': 7.21.3(@babel/core@7.21.8) + '@babel/plugin-transform-flow-strip-types': 7.21.0(@babel/core@7.21.8) + '@babel/plugin-transform-for-of': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-transform-function-name': 7.18.9(@babel/core@7.21.8) + '@babel/plugin-transform-literals': 7.18.9(@babel/core@7.21.8) + '@babel/plugin-transform-member-expression-literals': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-modules-commonjs': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-transform-object-super': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-parameters': 7.21.3(@babel/core@7.21.8) + '@babel/plugin-transform-property-literals': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-react-jsx': 7.21.5(@babel/core@7.21.8) + '@babel/plugin-transform-shorthand-properties': 7.18.6(@babel/core@7.21.8) + '@babel/plugin-transform-spread': 7.20.7(@babel/core@7.21.8) + '@babel/plugin-transform-template-literals': 7.18.9(@babel/core@7.21.8) + babel-plugin-syntax-trailing-function-commas: 7.0.0-beta.0 + transitivePeerDependencies: + - supports-color + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: true + + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /browserslist@4.21.5: + resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001485 + electron-to-chromium: 1.4.385 + node-releases: 2.0.10 + update-browserslist-db: 1.0.11(browserslist@4.21.5) + dev: true + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + dev: true + + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + + /builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + dev: true + + /busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.4.1 + dev: true + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /caniuse-lite@1.0.30001485: + resolution: {integrity: sha512-8aUpZ7sjhlOyiNsg+pgcrTTPUXKh+rg544QYHSvQErljVEKJzvkYkCR/hUFeeVoEfTToUtY9cUKNRC7+c45YkA==} + dev: true + + /capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.1 + upper-case-first: 2.0.2 + dev: true + + /chai@4.3.7: + resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.2 + deep-eql: 4.1.3 + get-func-name: 2.0.0 + loupe: 2.3.6 + pathval: 1.1.1 + type-detect: 4.0.8 + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /change-case-all@1.0.15: + resolution: {integrity: sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==} + dependencies: + change-case: 4.1.2 + is-lower-case: 2.0.2 + is-upper-case: 2.0.2 + lower-case: 2.0.2 + lower-case-first: 2.0.2 + sponge-case: 1.0.1 + swap-case: 2.0.2 + title-case: 3.0.3 + upper-case: 2.0.2 + upper-case-first: 2.0.2 + dev: true + + /change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + dev: true + + /check-error@1.0.2: + resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} + dev: true + + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + dev: true + + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-spinners@2.9.0: + resolution: {integrity: sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==} + engines: {node: '>=6'} + dev: true + + /cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + dev: true + + /cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + dev: true + + /cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + dev: true + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: true + + /common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + dev: true + + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /concurrently@7.6.0: + resolution: {integrity: sha512-BKtRgvcJGeZ4XttiDiNcFiRlxoAeZOseqUvyYRUp/Vtd+9p1ULmeoSqGsDA+2ivdeDFpqrJvGvmI+StKfKl5hw==} + engines: {node: ^12.20.0 || ^14.13.0 || >=16.0.0} + hasBin: true + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.21 + rxjs: 7.8.1 + shell-quote: 1.8.1 + spawn-command: 0.0.2-1 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + dev: true + + /constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.1 + upper-case: 2.0.2 + dev: true + + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + dev: true + + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: true + + /cosmiconfig-typescript-loader@4.3.0(@types/node@18.14.2)(cosmiconfig@7.1.0)(ts-node@10.9.1)(typescript@4.9.5): + resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=7' + ts-node: '>=10' + typescript: '>=3' + dependencies: + '@types/node': 18.14.2 + cosmiconfig: 7.1.0 + ts-node: 10.9.1(@types/node@18.14.2)(typescript@4.9.5) + typescript: 4.9.5 + dev: true + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.0 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: true + + /cosmiconfig@8.0.0: + resolution: {integrity: sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ==} + engines: {node: '>=14'} + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + dev: true + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + + /cross-fetch@3.1.5: + resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==} + dependencies: + node-fetch: 2.6.7 + transitivePeerDependencies: + - encoding + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /dataloader@2.2.2: + resolution: {integrity: sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==} + dev: true + + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.21.5 + dev: true + + /debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + dev: true + + /deep-eql@4.1.3: + resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} + engines: {node: '>=6'} + dependencies: + type-detect: 4.0.8 + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: true + + /defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + dependencies: + clone: 1.0.4 + dev: true + + /dependency-graph@0.11.0: + resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} + engines: {node: '>= 0.6.0'} + dev: true + + /detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /devalue@4.3.0: + resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==} + dev: true + + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /dotenv@16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + dev: true + + /dset@3.1.2: + resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==} + engines: {node: '>=4'} + dev: true + + /electron-to-chromium@1.4.385: + resolution: {integrity: sha512-L9zlje9bIw0h+CwPQumiuVlfMcV4boxRjFIWDcLfFqTZNbkwOExBzfmswytHawObQX4OUhtNv8gIiB21kOurIg==} + dev: true + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + dev: true + + /es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + dev: true + + /esbuild-android-64@0.15.18: + resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64@0.15.18: + resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64@0.15.18: + resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64@0.15.18: + resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64@0.15.18: + resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64@0.15.18: + resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32@0.15.18: + resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64@0.15.18: + resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64@0.15.18: + resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm@0.15.18: + resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le@0.15.18: + resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le@0.15.18: + resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64@0.15.18: + resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x@0.15.18: + resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64@0.15.18: + resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64@0.15.18: + resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64@0.15.18: + resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32@0.15.18: + resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64@0.15.18: + resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64@0.15.18: + resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild@0.15.18: + resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.15.18 + '@esbuild/linux-loong64': 0.15.18 + esbuild-android-64: 0.15.18 + esbuild-android-arm64: 0.15.18 + esbuild-darwin-64: 0.15.18 + esbuild-darwin-arm64: 0.15.18 + esbuild-freebsd-64: 0.15.18 + esbuild-freebsd-arm64: 0.15.18 + esbuild-linux-32: 0.15.18 + esbuild-linux-64: 0.15.18 + esbuild-linux-arm: 0.15.18 + esbuild-linux-arm64: 0.15.18 + esbuild-linux-mips64le: 0.15.18 + esbuild-linux-ppc64le: 0.15.18 + esbuild-linux-riscv64: 0.15.18 + esbuild-linux-s390x: 0.15.18 + esbuild-netbsd-64: 0.15.18 + esbuild-openbsd-64: 0.15.18 + esbuild-sunos-64: 0.15.18 + esbuild-windows-32: 0.15.18 + esbuild-windows-64: 0.15.18 + esbuild-windows-arm64: 0.15.18 + dev: true + + /esbuild@0.16.17: + resolution: {integrity: sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.16.17 + '@esbuild/android-arm64': 0.16.17 + '@esbuild/android-x64': 0.16.17 + '@esbuild/darwin-arm64': 0.16.17 + '@esbuild/darwin-x64': 0.16.17 + '@esbuild/freebsd-arm64': 0.16.17 + '@esbuild/freebsd-x64': 0.16.17 + '@esbuild/linux-arm': 0.16.17 + '@esbuild/linux-arm64': 0.16.17 + '@esbuild/linux-ia32': 0.16.17 + '@esbuild/linux-loong64': 0.16.17 + '@esbuild/linux-mips64el': 0.16.17 + '@esbuild/linux-ppc64': 0.16.17 + '@esbuild/linux-riscv64': 0.16.17 + '@esbuild/linux-s390x': 0.16.17 + '@esbuild/linux-x64': 0.16.17 + '@esbuild/netbsd-x64': 0.16.17 + '@esbuild/openbsd-x64': 0.16.17 + '@esbuild/sunos-x64': 0.16.17 + '@esbuild/win32-arm64': 0.16.17 + '@esbuild/win32-ia32': 0.16.17 + '@esbuild/win32-x64': 0.16.17 + dev: true + + /escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /eslint-config-prettier@8.5.0(eslint@8.28.0): + resolution: {integrity: sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.28.0 + dev: true + + /eslint-plugin-svelte3@4.0.0(eslint@8.28.0)(svelte@3.54.0): + resolution: {integrity: sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g==} + peerDependencies: + eslint: '>=8.0.0' + svelte: ^3.2.0 + dependencies: + eslint: 8.28.0 + svelte: 3.54.0 + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.0: + resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-utils@3.0.0(eslint@8.28.0): + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + dependencies: + eslint: 8.28.0 + eslint-visitor-keys: 2.1.0 + dev: true + + /eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + dev: true + + /eslint-visitor-keys@3.4.1: + resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.28.0: + resolution: {integrity: sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint/eslintrc': 1.4.1 + '@humanwhocodes/config-array': 0.11.8 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.0 + eslint-utils: 3.0.0(eslint@8.28.0) + eslint-visitor-keys: 3.4.1 + espree: 9.5.2 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.20.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-sdsl: 4.4.0 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.1 + regexpp: 3.2.0 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + dev: true + + /espree@9.5.2: + resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.8.2 + acorn-jsx: 5.3.2(acorn@8.8.2) + eslint-visitor-keys: 3.4.1 + dev: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + dev: true + + /extract-files@11.0.0: + resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==} + engines: {node: ^12.20 || >= 14.13} + dev: true + + /fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-glob@3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fast-querystring@1.1.1: + resolution: {integrity: sha512-qR2r+e3HvhEFmpdHMv//U8FnFlnYjaC6QKDuaXALDkw2kvHO8WDjxH+f/rHGR4Me4pnk8p9JAkRNTjYHAKRn2Q==} + dependencies: + fast-decode-uri-component: 1.0.1 + dev: true + + /fast-url-parser@1.1.3: + resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} + dependencies: + punycode: 1.4.1 + dev: true + + /fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + dependencies: + reusify: 1.0.4 + dev: true + + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + dev: true + + /fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + dev: true + + /fbjs@3.0.4: + resolution: {integrity: sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==} + dependencies: + cross-fetch: 3.1.5 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 0.7.35 + transitivePeerDependencies: + - encoding + dev: true + + /figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flatted@3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /fs-extra@11.1.0: + resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /get-func-name@2.0.0: + resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + dev: true + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + dev: true + + /globals@13.20.0: + resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.12 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: true + + /graphql-config@4.5.0(@types/node@18.14.2)(graphql@16.6.0): + resolution: {integrity: sha512-x6D0/cftpLUJ0Ch1e5sj1TZn6Wcxx4oMfmhaG9shM0DKajA9iR+j1z86GSTQ19fShbGvrSSvbIQsHku6aQ6BBw==} + engines: {node: '>= 10.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + dependencies: + '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.6.0) + '@graphql-tools/json-file-loader': 7.4.18(graphql@16.6.0) + '@graphql-tools/load': 7.8.14(graphql@16.6.0) + '@graphql-tools/merge': 8.4.1(graphql@16.6.0) + '@graphql-tools/url-loader': 7.17.18(@types/node@18.14.2)(graphql@16.6.0) + '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + cosmiconfig: 8.0.0 + graphql: 16.6.0 + jiti: 1.17.1 + minimatch: 4.2.3 + string-env-interpolation: 1.0.1 + tslib: 2.4.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - utf-8-validate + dev: true + + /graphql-request@6.0.0(graphql@16.6.0): + resolution: {integrity: sha512-2BmHTuglonjZvmNVw6ZzCfFlW/qkIPds0f+Qdi/Lvjsl3whJg2uvHmSvHnLWhUTEw6zcxPYAHiZoPvSVKOZ7Jw==} + peerDependencies: + graphql: 14 - 16 + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) + cross-fetch: 3.1.5 + graphql: 16.6.0 + transitivePeerDependencies: + - encoding + dev: true + + /graphql-tag@2.12.6(graphql@16.6.0): + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + graphql: 16.6.0 + tslib: 2.4.1 + dev: true + + /graphql-ws@5.11.3(graphql@16.6.0): + resolution: {integrity: sha512-fU8zwSgAX2noXAsuFiCZ8BtXeXZOzXyK5u1LloCdacsVth4skdBMPO74EG51lBoWSIZ8beUocdpV8+cQHBODnQ==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + dependencies: + graphql: 16.6.0 + dev: false + + /graphql-ws@5.12.1(graphql@16.6.0): + resolution: {integrity: sha512-umt4f5NnMK46ChM2coO36PTFhHouBrK9stWWBczERguwYrGnPNxJ9dimU6IyOBfOkC6Izhkg4H8+F51W/8CYDg==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + dependencies: + graphql: 16.6.0 + dev: true + + /graphql@16.6.0: + resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + dependencies: + capital-case: 1.0.4 + tslib: 2.4.1 + dev: true + + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: true + + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + dev: true + + /immutable@3.7.6: + resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} + engines: {node: '>=0.8.0'} + dev: true + + /immutable@4.3.0: + resolution: {integrity: sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==} + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /import-from@4.0.0: + resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} + engines: {node: '>=12.2'} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /inquirer@8.2.5: + resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} + engines: {node: '>=12.0.0'} + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 7.0.0 + dev: true + + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: true + + /is-absolute@1.0.0: + resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} + engines: {node: '>=0.10.0'} + dependencies: + is-relative: 1.0.0 + is-windows: 1.0.2 + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-builtin-module@3.2.1: + resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} + engines: {node: '>=6'} + dependencies: + builtin-modules: 3.3.0 + dev: true + + /is-core-module@2.12.0: + resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + + /is-lower-case@2.0.2: + resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} + dependencies: + tslib: 2.4.1 + dev: true + + /is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + dependencies: + '@types/estree': 1.0.1 + dev: true + + /is-relative@1.0.0: + resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} + engines: {node: '>=0.10.0'} + dependencies: + is-unc-path: 1.0.0 + dev: true + + /is-unc-path@1.0.0: + resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} + engines: {node: '>=0.10.0'} + dependencies: + unc-path-regex: 0.1.2 + dev: true + + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + + /is-upper-case@2.0.2: + resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} + dependencies: + tslib: 2.4.1 + dev: true + + /is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /isomorphic-ws@5.0.0(ws@8.13.0): + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + dependencies: + ws: 8.13.0 + dev: true + + /jiti@1.17.1: + resolution: {integrity: sha512-NZIITw8uZQFuzQimqjUxIrIcEdxYDFIe/0xYfIlVXTkiBjjyBEvgasj5bb0/cHtPRD/NziPbT312sFrkI5ALpw==} + hasBin: true + dev: true + + /jose@4.14.4: + resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==} + dev: true + + /js-sdsl@4.4.0: + resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json-stable-stringify@1.0.2: + resolution: {integrity: sha512-eunSSaEnxV12z+Z73y/j5N37/In40GK4GmsSy+tEHJMxknvqnA7/djeYtAgW0GsWHUfg+847WJjKaEylk2y09g==} + dependencies: + jsonify: 0.0.1 + dev: true + + /json-to-pretty-yaml@1.2.2: + resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} + engines: {node: '>= 0.2.0'} + dependencies: + remedial: 1.0.8 + remove-trailing-spaces: 1.0.8 + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + dev: true + + /kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + + /listr2@4.0.5: + resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} + engines: {node: '>=12'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.3.0 + rxjs: 7.8.1 + through: 2.3.8 + wrap-ansi: 7.0.0 + dev: true + + /local-pkg@0.4.3: + resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + engines: {node: '>=14'} + dev: true + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + dev: true + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + + /log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + dev: true + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + + /loupe@2.3.6: + resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} + dependencies: + get-func-name: 2.0.0 + dev: true + + /lower-case-first@2.0.2: + resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} + dependencies: + tslib: 2.4.1 + dev: true + + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.4.1 + dev: true + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /magic-string@0.30.0: + resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} + engines: {node: '>=0.10.0'} + dev: true + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /meros@1.2.1(@types/node@18.14.2): + resolution: {integrity: sha512-R2f/jxYqCAGI19KhAvaxSOxALBMkaXWH2a7rOyqQw+ZmizX5bKkEYWLzdhC+U82ZVVPVp6MCXe3EkVligh+12g==} + engines: {node: '>=13'} + peerDependencies: + '@types/node': '>=13' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 18.14.2 + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: true + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@4.2.3: + resolution: {integrity: sha512-lIUdtK5hdofgCTu3aT0sOaHsYR37viUuIc0rwnnDXImbwFRcumyLMeZaM0t0I/fgxS6s6JMfu0rLD1Wz9pv1ng==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + + /mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + dev: true + + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.4.1 + dev: true + + /node-addon-api@3.2.1: + resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + dev: true + + /node-fetch@2.6.7: + resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: true + + /node-fetch@2.6.9: + resolution: {integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: true + + /node-gyp-build@4.6.0: + resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} + hasBin: true + dev: true + + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + dev: true + + /node-releases@2.0.10: + resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==} + dev: true + + /normalize-path@2.1.1: + resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} + engines: {node: '>=0.10.0'} + dependencies: + remove-trailing-separator: 1.1.0 + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + dev: true + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + + /optionator@0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.3 + dev: true + + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + + /os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + dev: true + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + dependencies: + aggregate-error: 3.1.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: true + + /param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /parse-filepath@1.0.2: + resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} + engines: {node: '>=0.8'} + dependencies: + is-absolute: 1.0.0 + map-cache: 0.2.2 + path-root: 0.1.1 + dev: true + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.21.4 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: true + + /pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + dev: true + + /path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + dependencies: + path-root-regex: 0.1.2 + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /playwright-core@1.28.1: + resolution: {integrity: sha512-3PixLnGPno0E8rSBJjtwqTwJe3Yw72QwBBBxNoukIj3lEeBNXwbNiKrNuB1oyQgTBw5QHUhNO3SteEtHaMK6ag==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /postcss@8.4.23: + resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-plugin-svelte@2.8.1(prettier@2.8.0)(svelte@3.54.0): + resolution: {integrity: sha512-KA3K1J3/wKDnCxW7ZDRA/QL2Q67N7Xs3gOERqJ5X1qFjq1DdnN3K1R29scSKwh+kA8FF67pXbYytUpvN/i3iQw==} + peerDependencies: + prettier: ^1.16.4 || ^2.0.0 + svelte: ^3.2.0 + dependencies: + prettier: 2.8.0 + svelte: 3.54.0 + dev: true + + /prettier@2.8.0: + resolution: {integrity: sha512-9Lmg8hTFZKG0Asr/kW9Bp8tJjRVluO8EJQVfY2T7FMw9T5jy4I/Uvx0Rca/XWf50QQ1/SS48+6IJWnrb+2yemA==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + dependencies: + asap: 2.0.6 + dev: true + + /punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + dev: true + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /pvtsutils@1.3.2: + resolution: {integrity: sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==} + dependencies: + tslib: 2.4.1 + dev: true + + /pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: true + + /regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + dev: true + + /relay-runtime@12.0.0: + resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==} + dependencies: + '@babel/runtime': 7.21.5 + fbjs: 3.0.4 + invariant: 2.2.4 + transitivePeerDependencies: + - encoding + dev: true + + /remedial@1.0.8: + resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} + dev: true + + /remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + dev: true + + /remove-trailing-spaces@1.0.8: + resolution: {integrity: sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==} + dev: true + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + dev: true + + /resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rfdc@1.3.0: + resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + dev: true + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup@2.79.1: + resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /rollup@3.21.5: + resolution: {integrity: sha512-a4NTKS4u9PusbUJcfF4IMxuqjFzjm6ifj76P54a7cKnvVzJaG12BLVR+hgU2YDGHzyMMQNxLAZWuALsn8q2oQg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.4.1 + dev: true + + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sass@1.58.3: + resolution: {integrity: sha512-Q7RaEtYf6BflYrQ+buPudKR26/lH+10EmO9bBqbmPh/KeLqv8bjpTNqxe71ocONqXq+jYiCbpPUmQMS+JJPk4A==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.3.0 + source-map-js: 1.0.2 + dev: true + + /scuid@1.1.0: + resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} + dev: true + + /semver@6.3.0: + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + hasBin: true + dev: true + + /semver@7.5.0: + resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + dependencies: + no-case: 3.0.4 + tslib: 2.4.1 + upper-case-first: 2.0.2 + dev: true + + /set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + dev: true + + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + dev: true + + /setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /signedsource@1.0.0: + resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + dev: true + + /sirv@2.0.3: + resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.21 + mrmime: 1.0.1 + totalist: 3.0.1 + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.4.1 + dev: true + + /sorcery@0.11.0: + resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} + hasBin: true + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + buffer-crc32: 0.2.13 + minimist: 1.2.8 + sander: 0.5.1 + dev: true + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /spawn-command@0.0.2-1: + resolution: {integrity: sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==} + dev: true + + /sponge-case@1.0.1: + resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} + dependencies: + tslib: 2.4.1 + dev: true + + /streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: true + + /string-env-interpolation@1.0.1: + resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} + dev: true + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /strip-literal@0.4.2: + resolution: {integrity: sha512-pv48ybn4iE1O9RLgCAN0iU4Xv7RlBTiit6DKmMiErbs9x1wH6vXBs45tWc0H5wUIF6TLTrKweqkmYF/iraQKNw==} + dependencies: + acorn: 8.8.2 + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-adapter-deno@0.9.0(@sveltejs/kit@1.5.0): + resolution: {integrity: sha512-BIo0tb3BXp9kimM9NQYR9+xqUXKFgMzxS8+5Ly256MoHl8F3ENMFDF0yCCyiqtf3M/LuZwQKq7Ko4PhpQZMUAQ==} + peerDependencies: + '@sveltejs/kit': ^1.0.0 + dependencies: + '@rollup/plugin-commonjs': 24.1.0(rollup@3.21.5) + '@rollup/plugin-json': 6.0.0(rollup@3.21.5) + '@rollup/plugin-node-resolve': 15.0.2(rollup@3.21.5) + '@sveltejs/kit': 1.5.0(svelte@3.54.0)(vite@4.1.1) + rollup: 3.21.5 + dev: true + + /svelte-check@3.0.1(@babel/core@7.21.8)(sass@1.58.3)(svelte@3.54.0): + resolution: {integrity: sha512-7YpHYWv6V2qhcvVeAlXixUPAlpLCXB1nZEQK0EItB3PtuYmENhKclbc5uKSJTodTwWR1y+4stKGcbH30k6A3Yw==} + hasBin: true + peerDependencies: + svelte: ^3.55.0 + dependencies: + '@jridgewell/trace-mapping': 0.3.18 + chokidar: 3.5.3 + fast-glob: 3.2.12 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 3.54.0 + svelte-preprocess: 5.0.3(@babel/core@7.21.8)(sass@1.58.3)(svelte@3.54.0)(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + dev: true + + /svelte-fa@3.0.3: + resolution: {integrity: sha512-GIikJjcVCD+5Y/x9hZc2R4gvuA0gVftacuWu1a+zVQWSFjFYZ+hhU825x+QNs2slsppfrgmFiUyU9Sz9gj4Rdw==} + dev: true + + /svelte-hmr@0.15.1(svelte@3.54.0): + resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.54.0 + dev: true + + /svelte-preprocess@5.0.3(@babel/core@7.21.8)(sass@1.58.3)(svelte@3.54.0)(typescript@4.9.5): + resolution: {integrity: sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==} + engines: {node: '>= 14.10.0'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@babel/core': 7.21.8 + '@types/pug': 2.0.6 + detect-indent: 6.1.0 + magic-string: 0.27.0 + sass: 1.58.3 + sorcery: 0.11.0 + strip-indent: 3.0.0 + svelte: 3.54.0 + typescript: 4.9.5 + dev: true + + /svelte-turnstile@0.3.1: + resolution: {integrity: sha512-b0A90zMMjm0U0S1zOP9R+msnG8+kBJNspBiLVzwaH7uOUmjFLt8ujArqlHWQhXj+bRZEMM2dXIYBykVDku7pXg==} + dev: true + + /svelte2tsx@0.6.2(svelte@3.54.0)(typescript@4.9.5): + resolution: {integrity: sha512-0ircYY2/jMOfistf+iq8fVHERnu1i90nku56c78+btC8svyafsc3OjOV37LDEOV7buqYY1Rv/uy03eMxhopH2Q==} + peerDependencies: + svelte: ^3.55 + typescript: ^4.9.4 + dependencies: + dedent-js: 1.0.1 + pascal-case: 3.1.2 + svelte: 3.54.0 + typescript: 4.9.5 + dev: true + + /svelte@3.54.0: + resolution: {integrity: sha512-tdrgeJU0hob0ZWAMoKXkhcxXA7dpTg6lZGxUeko5YqvPdJBiyRspGsCwV27kIrbrqPP2WUoSV9ca0gnLlw8YzQ==} + engines: {node: '>= 8'} + + /swap-case@2.0.2: + resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + dependencies: + tslib: 2.4.1 + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true + + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + + /tinybench@2.5.0: + resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} + dev: true + + /tinypool@0.3.1: + resolution: {integrity: sha512-zLA1ZXlstbU2rlpA4CIeVaqvWq41MTWqLY3FfsAXgC8+f7Pk7zroaJQxDgxn1xNudKW6Kmj4808rPFShUlIRmQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@1.1.1: + resolution: {integrity: sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g==} + engines: {node: '>=14.0.0'} + dev: true + + /title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + dependencies: + tslib: 2.4.1 + dev: true + + /tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + dependencies: + os-tmpdir: 1.0.2 + dev: true + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: true + + /tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + dev: true + + /ts-log@2.2.5: + resolution: {integrity: sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==} + dev: true + + /ts-node@10.9.1(@types/node@18.14.2)(typescript@4.9.5): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.3 + '@types/node': 18.14.2 + acorn: 8.8.2 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tslib@2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + dev: true + + /tslib@2.5.0: + resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} + dev: true + + /tsutils@3.21.0(typescript@4.9.5): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 4.9.5 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + dev: true + + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /ua-parser-js@0.7.35: + resolution: {integrity: sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==} + dev: true + + /unc-path-regex@0.1.2: + resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} + engines: {node: '>=0.10.0'} + dev: true + + /undici@5.18.0: + resolution: {integrity: sha512-1iVwbhonhFytNdg0P4PqyIAXbdlVZVebtPDvuM36m66mRw4OGrCm2MYynJv/UENFLdP13J1nPVQzVE2zTs1OeA==} + engines: {node: '>=12.18'} + dependencies: + busboy: 1.6.0 + dev: true + + /universalify@2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /unixify@1.0.0: + resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} + engines: {node: '>=0.10.0'} + dependencies: + normalize-path: 2.1.1 + dev: true + + /update-browserslist-db@1.0.11(browserslist@4.21.5): + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.5 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + + /upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + dependencies: + tslib: 2.4.1 + dev: true + + /upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + dependencies: + tslib: 2.4.1 + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + dev: true + + /urql@3.0.3(graphql@16.6.0)(react@18.2.0): + resolution: {integrity: sha512-aVUAMRLdc5AOk239DxgXt6ZxTl/fEmjr7oyU5OGo8uvpqu42FkeJErzd2qBzhAQ3DyusoZIbqbBLPlnKo/yy2A==} + peerDependencies: + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: '>= 16.8.0' + dependencies: + '@urql/core': 3.2.2(graphql@16.6.0) + graphql: 16.6.0 + react: 18.2.0 + wonka: 6.2.3 + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + + /value-or-promise@1.0.12: + resolution: {integrity: sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==} + engines: {node: '>=12'} + dev: true + + /vite@3.2.6(@types/node@18.14.2)(sass@1.58.3): + resolution: {integrity: sha512-nTXTxYVvaQNLoW5BQ8PNNQ3lPia57gzsQU/Khv+JvzKPku8kNZL6NMUR/qwXhMG6E+g1idqEPanomJ+VZgixEg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.14.2 + esbuild: 0.15.18 + postcss: 8.4.23 + resolve: 1.22.2 + rollup: 2.79.1 + sass: 1.58.3 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /vite@4.1.1(@types/node@18.14.2)(sass@1.58.3): + resolution: {integrity: sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.14.2 + esbuild: 0.16.17 + postcss: 8.4.23 + resolve: 1.22.2 + rollup: 3.21.5 + sass: 1.58.3 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /vitefu@0.2.4(vite@4.1.1): + resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 4.1.1(@types/node@18.14.2)(sass@1.58.3) + dev: true + + /vitest@0.25.3(sass@1.58.3): + resolution: {integrity: sha512-/UzHfXIKsELZhL7OaM2xFlRF8HRZgAHtPctacvNK8H4vOcbJJAMEgbWNGSAK7Y9b1NBe5SeM7VTuz2RsTHFJJA==} + engines: {node: '>=v14.16.0'} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@vitest/browser': '*' + '@vitest/ui': '*' + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/chai': 4.3.5 + '@types/chai-subset': 1.3.3 + '@types/node': 18.14.2 + acorn: 8.8.2 + acorn-walk: 8.2.0 + chai: 4.3.7 + debug: 4.3.4 + local-pkg: 0.4.3 + source-map: 0.6.1 + strip-literal: 0.4.2 + tinybench: 2.5.0 + tinypool: 0.3.1 + tinyspy: 1.1.1 + vite: 3.2.6(@types/node@18.14.2)(sass@1.58.3) + transitivePeerDependencies: + - less + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + dependencies: + defaults: 1.0.4 + dev: true + + /web-streams-polyfill@3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: true + + /webcrypto-core@1.7.7: + resolution: {integrity: sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==} + dependencies: + '@peculiar/asn1-schema': 2.3.6 + '@peculiar/json-schema': 1.1.12 + asn1js: 3.0.5 + pvtsutils: 1.3.2 + tslib: 2.4.1 + dev: true + + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: true + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: true + + /which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wonka@6.2.3: + resolution: {integrity: sha512-EFOYiqDeYLXSzGYt2X3aVe9Hq1XJG+Hz/HjTRRT4dZE9q95khHl5+7pzUSXI19dbMO1/2UMrTf7JT7/7JrSQSQ==} + dev: false + + /word-wrap@1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} + dev: true + + /wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /ws@8.13.0: + resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + dev: true + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + dev: true + + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: true + + /yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /zod@3.20.6: + resolution: {integrity: sha512-oyu0m54SGCtzh6EClBVqDDlAYRz4jrVtKwQ7ZnsEmMI9HnzuZFj8QFwAY1M5uniIYACdGvv0PBWPF2kO0aNofA==} + dev: false diff --git a/frontend/website/src/components/login.svelte b/frontend/website/src/components/login.svelte index 7265966d..2584e36e 100644 --- a/frontend/website/src/components/login.svelte +++ b/frontend/website/src/components/login.svelte @@ -329,6 +329,7 @@ token user { id + displayName username email emailVerified @@ -363,6 +364,7 @@ token user { id + displayName username email emailVerified diff --git a/frontend/website/src/lib/user.ts b/frontend/website/src/lib/user.ts index 0c417f37..4612976b 100644 --- a/frontend/website/src/lib/user.ts +++ b/frontend/website/src/lib/user.ts @@ -1,10 +1,10 @@ import { client } from "$lib/gql"; import { get } from "svelte/store"; import { graphql } from "../gql"; -import type { User } from "../gql/graphql"; import { sessionToken } from "../store/login"; import { user } from "../store/user"; import { websocketOpen } from "../store/websocket"; +import type { User } from "../types/index"; async function verifyToken(token: string): Promise { const result = await client @@ -15,6 +15,7 @@ async function verifyToken(token: string): Promise { loginWithToken(sessionToken: $token, updateContext: true) { user { id + displayName username email emailVerified diff --git a/frontend/website/src/store/user.ts b/frontend/website/src/store/user.ts index 792818e9..c1fdab86 100644 --- a/frontend/website/src/store/user.ts +++ b/frontend/website/src/store/user.ts @@ -1,12 +1,5 @@ import { writable } from "svelte/store"; -interface User { - id: number; - username: string; - email: string; - emailVerified: boolean; - createdAt: string; - lastLoginAt: string; -} +import type { User } from "../types/index"; export const user = writable(null as User | null); diff --git a/frontend/website/src/types/index.ts b/frontend/website/src/types/index.ts new file mode 100644 index 00000000..767c5d2b --- /dev/null +++ b/frontend/website/src/types/index.ts @@ -0,0 +1 @@ +export type { User } from "./user"; diff --git a/frontend/website/src/types/user.ts b/frontend/website/src/types/user.ts new file mode 100644 index 00000000..b350d33e --- /dev/null +++ b/frontend/website/src/types/user.ts @@ -0,0 +1,9 @@ +export interface User { + id: string; + displayName: string; + username: string; + email: string; + emailVerified: boolean; + createdAt: string; + lastLoginAt: string; +} diff --git a/frontend/website/svelte.config.js b/frontend/website/svelte.config.js index 42372f89..e733664d 100644 --- a/frontend/website/svelte.config.js +++ b/frontend/website/svelte.config.js @@ -22,11 +22,6 @@ const config = { ); }, }), - typescript: { - config(config) { - config.include.push("../wasm.d.ts"); - }, - }, }, }; diff --git a/frontend/website/vite.config.ts b/frontend/website/vite.config.ts index ab898663..d8786fb5 100644 --- a/frontend/website/vite.config.ts +++ b/frontend/website/vite.config.ts @@ -1,15 +1,16 @@ +import { defineConfig, searchForWorkspaceRoot } from "vite"; import { sveltekit } from "@sveltejs/kit/vite"; -import { defineConfig } from "vitest/config"; -import wasm from "./plugins/wasm"; import { resolve } from "path"; export default defineConfig({ - plugins: [sveltekit(), wasm({ directory: "../player", name: "player" })], - test: { - include: ["src/**/*.{test,spec}.{js,ts}"], - }, + plugins: [sveltekit()], optimizeDeps: { - exclude: ["@urql/svelte", "urql", "@urql/core"], + exclude: ["@urql/svelte", "urql", "@urql/core", "@scuffle/player"], + }, + server: { + fs: { + allow: [searchForWorkspaceRoot(process.cwd())], + }, }, resolve: { alias: { diff --git a/maskfile.md b/maskfile.md index 45f2318d..4f9a9b04 100644 --- a/maskfile.md +++ b/maskfile.md @@ -6,45 +6,20 @@ -**OPTIONS** - -- container - - flags: --container - - desc: Build the project in a container - ```bash set -e if [[ "$verbose" == "true" ]]; then set -x fi -if [ "$container" == "true" ]; then - $MASK env backup - - function cleanup { - $MASK env restore - docker stop $PID >> /dev/null - } - trap cleanup EXIT - - PID=$(docker run -d --stop-signal SIGKILL --rm -v "$(pwd)":/pwd -w /pwd ghcr.io/scuffletv/build:latest mask build) - docker logs -f $PID -else - $MASK build rust - $MASK build website -fi +$MASK build rust +$MASK build website ``` ### rust > Build all rust code -**OPTIONS** - -- container - - flags: --container - - desc: Build the project in a container - ```bash set -e if [[ "$verbose" == "true" ]]; then @@ -53,20 +28,7 @@ fi target=$(rustup show active-toolchain | cut -d '-' -f2- | cut -d ' ' -f1) -if [ "$container" == "true" ]; then - $MASK env backup - - function cleanup { - $MASK env restore - docker stop $PID >> /dev/null - } - trap cleanup EXIT - - PID=$(docker run -d --stop-signal SIGKILL --rm -v "$(pwd)":/pwd -w /pwd ghcr.io/scuffletv/build:latest mask build rust --static=$static) - docker logs -f $PID -else - cargo build --release --target=$target -fi +cargo build --release --target=$target ``` ### website @@ -75,12 +37,12 @@ fi **OPTIONS** -- container - - flags: --container - - desc: Build the project in a container - no_gql_prepare - flags: --no-gql-prepare - desc: Don't prepare the GraphQL schema +- no_player + - flags: --no-player + - desc: Don't build the player ```bash set -e @@ -88,24 +50,41 @@ if [[ "$verbose" == "true" ]]; then set -x fi -if [ "$container" == "true" ]; then - $MASK env backup +if [ "$no_gql_prepare" != "true" ]; then + $MASK gql prepare + export SCHEMA_URL=$(realpath frontend/website/schema.graphql) +fi + +if [ "$no_player" != "true" ]; then + $MASK build player +fi - function cleanup { - $MASK env restore - docker stop $PID >> /dev/null - } - trap cleanup EXIT +pnpm --filter website build +``` - PID=$(docker run -d --stop-signal SIGKILL --rm -v "$(pwd)":/pwd -w /pwd ghcr.io/scuffletv/build:1.67.1 yarn workspace website build) - docker logs -f $PID -else - if [ "$no_gql_prepare" != "true" ]; then - $MASK gql prepare - export SCHEMA_URL=$(realpath frontend/website/schema.graphql) - fi +### player + +> Build the player + +**OPTIONS** - yarn workspace website build +- dev + - flags: --dev + - desc: Build the player in dev mode +- no_demo + - flags: --no-demo + - desc: Do not build the demo + +```bash +set -e +if [[ "$verbose" == "true" ]]; then + set -x +fi + +if [ "$dev" == "true" ]; then + pnpm --filter @scuffle/player build:dev +else + pnpm --filter @scuffle/player build fi ``` @@ -140,7 +119,7 @@ if [[ "$all" == "true" ]]; then fi cargo clean -yarn workspace website clean +pnpm --recursive --parallel --stream run clean if [ "$node_modules" == "true" ]; then rm -rf node_modules @@ -169,6 +148,10 @@ fi - flags: --no-terraform - type: bool - desc: Disables Terraform formatting +- no_proto + - flags: --no-proto + - type: bool + - desc: Disables Protobuf formatting ```bash set -e @@ -179,17 +162,19 @@ fi if [ "$no_rust" != "true" ]; then cargo fmt --all cargo clippy --fix --allow-dirty --allow-staged - cargo clippy --fix --allow-dirty --allow-staged --package player --target wasm32-unknown-unknown fi if [ "$no_js" != "true" ]; then - yarn format - yarn workspace website format + pnpm --recursive --parallel --stream run format fi if [ "$no_terraform" != "true" ]; then terraform fmt -recursive fi + +if [ "$no_proto" != "true" ]; then + find . -name '*.proto' -exec clang-format -i {} \; +fi ``` ## lint @@ -210,6 +195,10 @@ fi - flags: --no-terraform - type: bool - desc: Disables Terraform linting +- no_proto + - flags: --no-proto + - type: bool + - desc: Disables Protobuf linting ```bash set -e @@ -219,20 +208,22 @@ fi if [ "$no_rust" != "true" ]; then cargo clippy -- -D warnings - cargo clippy --package player --target wasm32-unknown-unknown -- -D warnings cargo fmt --all --check cargo sqlx prepare --check --workspace -- --all-targets --all-features $MASK gql check fi if [ "$no_js" != "true" ]; then - yarn lint - yarn workspace website lint + pnpm --recursive --parallel --stream run lint fi if [ "$no_terraform" != "true" ]; then terraform fmt -check -recursive fi + +if [ "$no_proto" != "true" ]; then + find . -name '*.proto' -exec clang-format --dry-run --Werror {} \; +fi ``` ## audit @@ -258,10 +249,11 @@ fi if [ "$no_rust" != "true" ]; then cargo audit + cd frontend/player && cargo audit && cd ../.. fi if [ "$no_js" != "true" ]; then - yarn audit + pnpm audit fi ``` @@ -279,6 +271,14 @@ fi - flags: --no-js - type: bool - desc: Disables JS testing +- no_player_build + - flags: --no-player-build + - type: bool + - desc: Disables Player Building +- ci + - flags: --ci + - type: bool + - desc: Runs tests in CI mode ```bash set -e @@ -288,11 +288,19 @@ fi if [ "$no_rust" != "true" ]; then cargo llvm-cov clean --workspace - cargo llvm-cov nextest --lcov --output-path lcov.info --ignore-filename-regex "(main\.rs|tests|.*\.nocov\.rs)" --workspace + if [ "$ci" == "true" ]; then + cargo llvm-cov nextest --lcov --output-path lcov.info --ignore-filename-regex "(main\.rs|tests|.*\.nocov\.rs)" --workspace --no-fail-fast -E "not test(_v6)" --status-level all + else + cargo llvm-cov nextest --lcov --output-path lcov.info --ignore-filename-regex "(main\.rs|tests|.*\.nocov\.rs)" --workspace + fi fi if [ "$no_js" != "true" ]; then - yarn workspace website test + if [ "$no_player_build" != "true" ]; then + $MASK build player --dev + fi + + pnpm --recursive --parallel --stream run test fi ``` @@ -360,7 +368,7 @@ fi cargo sqlx prepare --workspace -- --all-targets --all-features if [ "$no_format" != "true" ]; then - yarn prettier --write .sqlx + pnpm exec prettier --write .sqlx fi ``` @@ -431,7 +439,9 @@ if [[ "$verbose" == "true" ]]; then fi if [ ! -f .env ]; then - echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/scuffle-dev" > .env + echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/scuffle_dev" > .env + echo "RMQ_URL=amqp://rabbitmq:rabbitmq@localhost:5672/scuffle" >> .env + echo "REDIS_URL=redis://localhost:6379/0" >> .env fi ``` @@ -465,85 +475,6 @@ if [ -f .env.bak ]; then fi ``` -## stack - -> Development stack tasks - -### up - -> Starts the docker compose stack - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -docker compose --file ./dev-stack/docker-compose.yml up -d --build -``` - -### down - -> Stops the docker compose stack - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -docker compose --file ./dev-stack/docker-compose.yml down -``` - -### init - -> Initializes the development stack - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -cp ./dev-stack/example.docker-compose.yml ./dev-stack/docker-compose.yml -``` - -### status - -> Gets the status of the docker compose stack - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -docker compose --file ./dev-stack/docker-compose.yml ps -a -``` - -### logs (service) - -> Prints the logs of the given service -> You can show logs of multiple services by passing a single string with space separated service names - -**OPTIONS** - -- follow - - flags: -f, --follow - - type: bool - - desc: Follow log output - -```bash -set -e -if [[ "$verbose" == "true" ]]; then - set -x -fi - -follow=${follow:-false} - -docker compose --file ./dev-stack/docker-compose.yml logs --follow=$follow $service -``` - ## bootstrap > Bootstrap the project @@ -570,10 +501,6 @@ docker compose --file ./dev-stack/docker-compose.yml logs --follow=$follow $serv - flags: --no-docker - type: bool - desc: Disables docker bootstrapping -- no_stack - - flags: --no-stack - - type: bool - - desc: Disables stack bootstrapping - no_db - flags: --no-db - type: bool @@ -587,24 +514,22 @@ fi if [ "$no_rust" != "true" ]; then rustup update - rustup target add wasm32-unknown-unknown rustup component add rustfmt clippy llvm-tools-preview cargo install cargo-binstall cargo binstall cargo-watch -y cargo install sqlx-cli --features native-tls,postgres --no-default-features --git https://github.com/launchbadge/sqlx --branch main - cargo binstall wasm-pack -y cargo binstall cargo-llvm-cov -y cargo binstall cargo-nextest -y cargo install cargo-audit --features vendored-openssl fi if [ "$no_js" != "true" ]; then - yarn install + pnpm --recursive --stream install if [ "$no_js_tests" != "true" ]; then - yarn playwright install + pnpm --filter website exec playwright install fi fi @@ -615,10 +540,6 @@ fi if [ "$no_docker" != "true" ]; then docker network create scuffle-dev || true - if [ "$no_stack" != "true" ]; then - $MASK stack init - fi - if [ "$no_db" != "true" ]; then $MASK db up $MASK db migrate @@ -640,7 +561,7 @@ if [[ "$verbose" == "true" ]]; then set -x fi -cargo run --bin api-gql-generator | yarn -s prettier --stdin-filepath schema.graphql > schema.graphql +cargo run --bin api-gql-generator | pnpm exec prettier --stdin-filepath schema.graphql > schema.graphql ``` ### check @@ -653,7 +574,20 @@ if [[ "$verbose" == "true" ]]; then set -x fi -cargo run --bin api-gql-generator | yarn -s prettier --stdin-filepath schema.graphql | diff - schema.graphql || (echo "GraphQL schema is out of date. Run 'mask gql prepare' to update it." && exit 1) +cargo run --bin api-gql-generator | pnpm exec prettier --stdin-filepath schema.graphql | diff - schema.graphql || (echo "GraphQL schema is out of date. Run 'mask gql prepare' to update it." && exit 1) echo "GraphQL schema is up to date." ``` + +## cloc + +> Count lines of code + +```bash +set -e +if [[ "$verbose" == "true" ]]; then + set -x +fi + +cloc $(git ls-files) +``` diff --git a/package.json b/package.json index 443f5b64..03474f64 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,8 @@ "@commitlint/config-conventional": "^17.4.3", "commitlint": "^17.4.3", "husky": "^8.0.3", - "lint-staged": "^13.1.2", "prettier": "^2.8.4" }, - "workspaces": [ - "frontend/website" - ], "scripts": { "prepare": "husky install", "lint": "prettier --check \"**/*\" -u", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..75c1af1e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1344 @@ +lockfileVersion: '6.0' + +devDependencies: + '@commitlint/cli': + specifier: ^17.4.3 + version: 17.4.3 + '@commitlint/config-conventional': + specifier: ^17.4.3 + version: 17.4.3 + commitlint: + specifier: ^17.4.3 + version: 17.4.3 + husky: + specifier: ^8.0.3 + version: 8.0.3 + prettier: + specifier: ^2.8.4 + version: 2.8.4 + +packages: + + /@babel/code-frame@7.21.4: + resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + + /@babel/helper-validator-identifier@7.19.1: + resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/highlight@7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.19.1 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + + /@commitlint/cli@17.4.3: + resolution: {integrity: sha512-IPTS7AZuBHgD0gl24El8HwuDM9zJN9JLa5KmZUQoFD1BQeGGdzAYJOnAr85CeJWpTDok0BGHDL0+4odnH0iTyA==} + engines: {node: '>=v14'} + hasBin: true + dependencies: + '@commitlint/format': 17.4.4 + '@commitlint/lint': 17.6.3 + '@commitlint/load': 17.5.0 + '@commitlint/read': 17.5.1 + '@commitlint/types': 17.4.4 + execa: 5.1.1 + lodash.isfunction: 3.0.9 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /@commitlint/config-conventional@17.4.3: + resolution: {integrity: sha512-8EsY2iDw74hCk3hIQSg7/E0R8/KtPjnFPZVwmmHxcjhZjkSykmxysefICPDnbI3xgxfov0zwL1WKDHM8zglJdw==} + engines: {node: '>=v14'} + dependencies: + conventional-changelog-conventionalcommits: 5.0.0 + dev: true + + /@commitlint/config-validator@17.4.4: + resolution: {integrity: sha512-bi0+TstqMiqoBAQDvdEP4AFh0GaKyLFlPPEObgI29utoKEYoPQTvF0EYqIwYYLEoJYhj5GfMIhPHJkTJhagfeg==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.4.4 + ajv: 8.12.0 + dev: true + + /@commitlint/ensure@17.4.4: + resolution: {integrity: sha512-AHsFCNh8hbhJiuZ2qHv/m59W/GRE9UeOXbkOqxYMNNg9pJ7qELnFcwj5oYpa6vzTSHtPGKf3C2yUFNy1GGHq6g==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.4.4 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + dev: true + + /@commitlint/execute-rule@17.4.0: + resolution: {integrity: sha512-LIgYXuCSO5Gvtc0t9bebAMSwd68ewzmqLypqI2Kke1rqOqqDbMpYcYfoPfFlv9eyLIh4jocHWwCK5FS7z9icUA==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/format@17.4.4: + resolution: {integrity: sha512-+IS7vpC4Gd/x+uyQPTAt3hXs5NxnkqAZ3aqrHd5Bx/R9skyCAWusNlNbw3InDbAK6j166D9asQM8fnmYIa+CXQ==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.4.4 + chalk: 4.1.2 + dev: true + + /@commitlint/is-ignored@17.6.3: + resolution: {integrity: sha512-LQbNdnPbxrpbcrVKR5yf51SvquqktpyZJwqXx3lUMF6+nT9PHB8xn3wLy8pi2EQv5Zwba484JnUwDE1ygVYNQA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.4.4 + semver: 7.5.0 + dev: true + + /@commitlint/lint@17.6.3: + resolution: {integrity: sha512-fBlXwt6SHJFgm3Tz+luuo3DkydAx9HNC5y4eBqcKuDuMVqHd2ugMNr+bQtx6riv9mXFiPoKp7nE4Xn/ls3iVDA==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/is-ignored': 17.6.3 + '@commitlint/parse': 17.4.4 + '@commitlint/rules': 17.6.1 + '@commitlint/types': 17.4.4 + dev: true + + /@commitlint/load@17.5.0: + resolution: {integrity: sha512-l+4W8Sx4CD5rYFsrhHH8HP01/8jEP7kKf33Xlx2Uk2out/UKoKPYMOIRcDH5ppT8UXLMV+x6Wm5osdRKKgaD1Q==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/config-validator': 17.4.4 + '@commitlint/execute-rule': 17.4.0 + '@commitlint/resolve-extends': 17.4.4 + '@commitlint/types': 17.4.4 + '@types/node': 20.1.0 + chalk: 4.1.2 + cosmiconfig: 8.1.3 + cosmiconfig-typescript-loader: 4.3.0(@types/node@20.1.0)(cosmiconfig@8.1.3)(ts-node@10.9.1)(typescript@5.0.4) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + resolve-from: 5.0.0 + ts-node: 10.9.1(@types/node@20.1.0)(typescript@5.0.4) + typescript: 5.0.4 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /@commitlint/message@17.4.2: + resolution: {integrity: sha512-3XMNbzB+3bhKA1hSAWPCQA3lNxR4zaeQAQcHj0Hx5sVdO6ryXtgUBGGv+1ZCLMgAPRixuc6en+iNAzZ4NzAa8Q==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/parse@17.4.4: + resolution: {integrity: sha512-EKzz4f49d3/OU0Fplog7nwz/lAfXMaDxtriidyGF9PtR+SRbgv4FhsfF310tKxs6EPj8Y+aWWuX3beN5s+yqGg==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/types': 17.4.4 + conventional-changelog-angular: 5.0.13 + conventional-commits-parser: 3.2.4 + dev: true + + /@commitlint/read@17.5.1: + resolution: {integrity: sha512-7IhfvEvB//p9aYW09YVclHbdf1u7g7QhxeYW9ZHSO8Huzp8Rz7m05aCO1mFG7G8M+7yfFnXB5xOmG18brqQIBg==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/top-level': 17.4.0 + '@commitlint/types': 17.4.4 + fs-extra: 11.1.1 + git-raw-commits: 2.0.11 + minimist: 1.2.8 + dev: true + + /@commitlint/resolve-extends@17.4.4: + resolution: {integrity: sha512-znXr1S0Rr8adInptHw0JeLgumS11lWbk5xAWFVno+HUFVN45875kUtqjrI6AppmD3JI+4s0uZlqqlkepjJd99A==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/config-validator': 17.4.4 + '@commitlint/types': 17.4.4 + import-fresh: 3.3.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + resolve-global: 1.0.0 + dev: true + + /@commitlint/rules@17.6.1: + resolution: {integrity: sha512-lUdHw6lYQ1RywExXDdLOKxhpp6857/4c95Dc/1BikrHgdysVUXz26yV0vp1GL7Gv+avx9WqZWTIVB7pNouxlfw==} + engines: {node: '>=v14'} + dependencies: + '@commitlint/ensure': 17.4.4 + '@commitlint/message': 17.4.2 + '@commitlint/to-lines': 17.4.0 + '@commitlint/types': 17.4.4 + execa: 5.1.1 + dev: true + + /@commitlint/to-lines@17.4.0: + resolution: {integrity: sha512-LcIy/6ZZolsfwDUWfN1mJ+co09soSuNASfKEU5sCmgFCvX5iHwRYLiIuoqXzOVDYOy7E7IcHilr/KS0e5T+0Hg==} + engines: {node: '>=v14'} + dev: true + + /@commitlint/top-level@17.4.0: + resolution: {integrity: sha512-/1loE/g+dTTQgHnjoCy0AexKAEFyHsR2zRB4NWrZ6lZSMIxAhBJnmCqwao7b4H8888PsfoTBCLBYIw8vGnej8g==} + engines: {node: '>=v14'} + dependencies: + find-up: 5.0.0 + dev: true + + /@commitlint/types@17.4.4: + resolution: {integrity: sha512-amRN8tRLYOsxRr6mTnGGGvB5EmW/4DDjLMgiwK3CCVEmN6Sr/6xePGEpWaspKkckILuUORCwe6VfDBw6uj4axQ==} + engines: {node: '>=v14'} + dependencies: + chalk: 4.1.2 + dev: true + + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@tsconfig/node10@1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.3: + resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} + dev: true + + /@types/minimist@1.2.2: + resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} + dev: true + + /@types/node@20.1.0: + resolution: {integrity: sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==} + dev: true + + /@types/normalize-package-data@2.4.1: + resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + dev: true + + /JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + dev: true + + /acorn-walk@8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@8.8.2: + resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + dev: true + + /arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /camelcase-keys@6.2.2: + resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + map-obj: 4.3.0 + quick-lru: 4.0.1 + dev: true + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /commitlint@17.4.3: + resolution: {integrity: sha512-3MGkngRG3x3KY5uKWxgyKK7WU5apelorn4jeJsu8aCotuaoPXYtZX8Ym7a/ZzB19UUuWADnKWVTWBePvweu3aA==} + engines: {node: '>=v14'} + hasBin: true + dependencies: + '@commitlint/cli': 17.4.3 + '@commitlint/types': 17.4.4 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + dev: true + + /compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + dev: true + + /conventional-changelog-angular@5.0.13: + resolution: {integrity: sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==} + engines: {node: '>=10'} + dependencies: + compare-func: 2.0.0 + q: 1.5.1 + dev: true + + /conventional-changelog-conventionalcommits@5.0.0: + resolution: {integrity: sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==} + engines: {node: '>=10'} + dependencies: + compare-func: 2.0.0 + lodash: 4.17.21 + q: 1.5.1 + dev: true + + /conventional-commits-parser@3.2.4: + resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==} + engines: {node: '>=10'} + hasBin: true + dependencies: + JSONStream: 1.3.5 + is-text-path: 1.0.1 + lodash: 4.17.21 + meow: 8.1.2 + split2: 3.2.2 + through2: 4.0.2 + dev: true + + /cosmiconfig-typescript-loader@4.3.0(@types/node@20.1.0)(cosmiconfig@8.1.3)(ts-node@10.9.1)(typescript@5.0.4): + resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=7' + ts-node: '>=10' + typescript: '>=3' + dependencies: + '@types/node': 20.1.0 + cosmiconfig: 8.1.3 + ts-node: 10.9.1(@types/node@20.1.0)(typescript@5.0.4) + typescript: 5.0.4 + dev: true + + /cosmiconfig@8.1.3: + resolution: {integrity: sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==} + engines: {node: '>=14'} + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + dev: true + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /dargs@7.0.0: + resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} + engines: {node: '>=8'} + dev: true + + /decamelize-keys@1.1.1: + resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} + engines: {node: '>=0.10.0'} + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + dev: true + + /decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + + /dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dependencies: + is-obj: 2.0.0 + dev: true + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + dev: true + + /escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + dev: true + + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /fs-extra@11.1.1: + resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: true + + /git-raw-commits@2.0.11: + resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + dargs: 7.0.0 + lodash: 4.17.21 + meow: 8.1.2 + split2: 3.2.2 + through2: 4.0.2 + dev: true + + /global-dirs@0.1.1: + resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} + engines: {node: '>=4'} + dependencies: + ini: 1.3.8 + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /hard-rejection@2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + dev: true + + /hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + dependencies: + lru-cache: 6.0.0 + dev: true + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: true + + /husky@8.0.3: + resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: true + + /is-core-module@2.12.0: + resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} + dependencies: + has: 1.0.3 + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + dev: true + + /is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + dev: true + + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + + /is-text-path@1.0.1: + resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} + engines: {node: '>=0.10.0'} + dependencies: + text-extensions: 1.9.0 + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: true + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + dev: true + + /kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: true + + /lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + dev: true + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: true + + /lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + dev: true + + /lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + dev: true + + /lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + dev: true + + /lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + dev: true + + /lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + dev: true + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /map-obj@1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + dev: true + + /map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + dev: true + + /meow@8.1.2: + resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} + engines: {node: '>=10'} + dependencies: + '@types/minimist': 1.2.2 + camelcase-keys: 6.2.2 + decamelize-keys: 1.1.1 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + dev: true + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimist-options@4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.2 + semver: 5.7.1 + validate-npm-package-license: 3.0.4 + dev: true + + /normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.12.0 + semver: 7.5.0 + validate-npm-package-license: 3.0.4 + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: true + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.21.4 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /prettier@2.8.4: + resolution: {integrity: sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /q@1.5.1: + resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} + engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + dev: true + + /quick-lru@4.0.1: + resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} + engines: {node: '>=8'} + dev: true + + /read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + dev: true + + /read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + dependencies: + '@types/normalize-package-data': 2.4.1 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + dev: true + + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: true + + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + dev: true + + /resolve-global@1.0.0: + resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} + engines: {node: '>=8'} + dependencies: + global-dirs: 0.1.1 + dev: true + + /resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /semver@5.7.1: + resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} + hasBin: true + dev: true + + /semver@7.5.0: + resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.13 + dev: true + + /spdx-exceptions@2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} + dev: true + + /spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.3.0 + spdx-license-ids: 3.0.13 + dev: true + + /spdx-license-ids@3.0.13: + resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} + dev: true + + /split2@3.2.2: + resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + dependencies: + readable-stream: 3.6.2 + dev: true + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /text-extensions@1.9.0: + resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} + engines: {node: '>=0.10'} + dev: true + + /through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + dependencies: + readable-stream: 3.6.2 + dev: true + + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true + + /trim-newlines@3.0.1: + resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} + engines: {node: '>=8'} + dev: true + + /ts-node@10.9.1(@types/node@20.1.0)(typescript@5.0.4): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.3 + '@types/node': 20.1.0 + acorn: 8.8.2 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.0.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + + /type-fest@0.18.1: + resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} + engines: {node: '>=10'} + dev: true + + /type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + dev: true + + /type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + dev: true + + /typescript@5.0.4: + resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} + engines: {node: '>=12.20'} + hasBin: true + dev: true + + /universalify@2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + + /validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true diff --git a/pnpm-workspaces.yaml b/pnpm-workspaces.yaml new file mode 100644 index 00000000..273da553 --- /dev/null +++ b/pnpm-workspaces.yaml @@ -0,0 +1,3 @@ +packages: +- 'frontend/player' +- 'frontend/website' diff --git a/proto/scuffle/backend/api.proto b/proto/scuffle/backend/api.proto new file mode 100644 index 00000000..2d6f9683 --- /dev/null +++ b/proto/scuffle/backend/api.proto @@ -0,0 +1,132 @@ +syntax = "proto3"; + +package scuffle.backend; + +import "scuffle/types/stream_variant.proto"; + +// This is an internal API for the Scuffle service. +// Used for communication between scuffle microservices. +service API { + // Method used by Ingest service to validate a stream key when a new publisher + // goes live. + rpc AuthenticateLiveStream(AuthenticateLiveStreamRequest) + returns (AuthenticateLiveStreamResponse) {} + + // Method used by the Ingest service to create a new stream. + // Only called if try_resumed is true and the stream could not be resumed. + rpc NewLiveStream(NewLiveStreamRequest) returns (NewLiveStreamResponse) {} + + // Method is used by the Ingest, Transcoder and Edge services, transcoder uses + // it to publish the variants and edge uses it to update the state of the + // stream (if it is ready to be played) and Ingest will use it to handle when + // the stream is stopped. + rpc UpdateLiveStream(UpdateLiveStreamRequest) + returns (UpdateLiveStreamResponse) {} +} + +// This request is created by the Ingest service when a new publisher goes live. +message AuthenticateLiveStreamRequest { + // The name of the app that the publisher is trying to go live on. + string app_name = 1; + // The stream key that the publisher is trying to go live with. + string stream_key = 2; + // The IP address of the publisher. + string ip_address = 3; + // Address of the ingest server which the publisher is connected to. + string ingest_address = 4; + // The connection ID of the publisher. + string connection_id = 5; +} + +// This response is sent back to the Ingest service, generated by the API +// service. +message AuthenticateLiveStreamResponse { + // A new stream ID to use for the stream. + string stream_id = 2; + // Whether the stream should be transcoded or not. + bool transcode = 3; + // should record the stream + bool record = 4; + // Try resume the live stream using the variants from the previous stream. + // If the stream was not stopped properly, this will be true. + // If its not possible to resume the stream, ingest should call NewLiveStream + // to create a new stream. + bool try_resume = 5; + // The variants of the stream. (if the stream was resumed) + repeated scuffle.types.StreamVariant variants = 6; +} + +// This request is created by the Ingest service when we attempt to resume a +// stream and it fails. +message NewLiveStreamRequest { + // The ID of the stream to create. + string old_stream_id = 1; + // The new variants of the stream. + repeated scuffle.types.StreamVariant variants = 2; +} + +// This response is sent back to the Ingest service, generated by the API +message NewLiveStreamResponse { + // The ID of the stream that was created. + string stream_id = 1; +} + +enum LiveStreamState { + NOT_READY = 0; + READY = 1; + STOPPED = 2; + STOPPED_RESUMABLE = 3; + FAILED = 4; +} + +message UpdateLiveStreamRequest { + // The ID of the stream to update. + string stream_id = 1; + + string connection_id = 2; + + // Once transcoding starts this message will be sent to the API service. + message Variants { + repeated scuffle.types.StreamVariant variants = 1; + } + + // If the stream is ready or has stopped, this message will be sent. + + // If the stream failed, this message will be sent. + message Event { + enum Level { + INFO = 0; + WARNING = 1; + ERROR = 2; + } + + // The title of the event. + string title = 1; + // The message in the event. + string message = 2; + // The level of the event + Level level = 3; + } + + message Bitrate { + // The bitrate of the stream. + uint64 video_bitrate = 1; + uint64 audio_bitrate = 2; + uint64 metadata_bitrate = 3; + } + + // We only need oneof these fields to be set. + message Update { + uint64 timestamp = 1; + oneof update { + Variants variants = 2; + LiveStreamState state = 3; + Bitrate bitrate = 4; + Event event = 5; + } + } + + repeated Update updates = 3; +} + +message UpdateLiveStreamResponse {} diff --git a/proto/scuffle/events/ingest.proto b/proto/scuffle/events/ingest.proto new file mode 100644 index 00000000..a39f28ac --- /dev/null +++ b/proto/scuffle/events/ingest.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package scuffle.events; + +message IngestMessage { + string id = 1; + uint64 timestamp = 2; + + oneof data { + IngestMessageDropStream drop_stream = 3; + } +} + +message IngestMessageDropStream { + string id = 1; +} diff --git a/proto/scuffle/events/transcoder.proto b/proto/scuffle/events/transcoder.proto new file mode 100644 index 00000000..0dc1a51f --- /dev/null +++ b/proto/scuffle/events/transcoder.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package scuffle.events; + +import "scuffle/types/stream_variant.proto"; + +message TranscoderMessage { + string id = 1; + uint64 timestamp = 2; + + oneof data { + TranscoderMessageNewStream new_stream = 3; + } +} + +message TranscoderMessageNewStream { + string request_id = 1; + string stream_id = 2; + string ingest_address = 3; + repeated scuffle.types.StreamVariant variants = 4; +} diff --git a/proto/scuffle/types/stream_variant.proto b/proto/scuffle/types/stream_variant.proto new file mode 100644 index 00000000..ed4402b2 --- /dev/null +++ b/proto/scuffle/types/stream_variant.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package scuffle.types; + +// A variant is a transcoded version of the stream. +message StreamVariant { + // The id of the variant. + string id = 1; + + // The name of the variant. + string name = 3; + + message VideoSettings { + // The width of the video. + uint32 width = 1; + // The height of the video. + uint32 height = 2; + // The framerate of the video. + uint32 framerate = 3; + // The bitrate of the video. + uint32 bitrate = 4; + // The codec of the video. + string codec = 5; + } + + message AudioSettings { + // The sample rate of the audio. + uint32 sample_rate = 1; + // The number of channels of the audio. + uint32 channels = 2; + // The bitrate of the audio. + uint32 bitrate = 3; + // The codec of the audio. + string codec = 4; + } + + // The video settings of the variant. (If the variant is audio-only, this will + // be empty.) + optional VideoSettings video_settings = 4; + // The audio settings of the variant. + optional AudioSettings audio_settings = 5; + + // The metadata of the variant. (This is a JSON string.) + string metadata = 6; +} diff --git a/proto/scuffle/utils/health.proto b/proto/scuffle/utils/health.proto new file mode 100644 index 00000000..eddb5a76 --- /dev/null +++ b/proto/scuffle/utils/health.proto @@ -0,0 +1,62 @@ +// This is a health proto from the GRPC project. +// Every service should implement this interface. +// So that the health of the service can be checked. +// A lot of loadbalancers use this to check the health of the service. + +// Copyright 2015 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical version of this proto can be found at +// https://github.com/grpc/grpc/blob/6f81b87122aec1194059f91289c527b3c08dc025/src/proto/grpc/health/v1/health.proto + +syntax = "proto3"; + +package grpc.health.v1; + +message HealthCheckRequest { + string service = 1; +} + +message HealthCheckResponse { + enum ServingStatus { + UNKNOWN = 0; + SERVING = 1; + NOT_SERVING = 2; + SERVICE_UNKNOWN = 3; // Used only by the Watch method. + } + ServingStatus status = 1; +} + +service Health { + // If the requested service is unknown, the call will fail with status + // NOT_FOUND. + rpc Check(HealthCheckRequest) returns (HealthCheckResponse); + + // Performs a watch for the serving status of the requested service. + // The server will immediately send back a message indicating the current + // serving status. It will then subsequently send a new message whenever + // the service's serving status changes. + // + // If the requested service is unknown when the call is received, the + // server will send a message setting the serving status to + // SERVICE_UNKNOWN but will *not* terminate the call. If at some + // future point, the serving status of the service becomes known, the + // server will send a new message with the service's serving status. + // + // If the call terminates with status UNIMPLEMENTED, then clients + // should assume this method is not supported and should not retry the + // call. If the call terminates with any other status (including OK), + // clients should retry the call with appropriate exponential backoff. + rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); +} diff --git a/proto/scuffle/video/edge.proto b/proto/scuffle/video/edge.proto new file mode 100644 index 00000000..03542b40 --- /dev/null +++ b/proto/scuffle/video/edge.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package scuffle.video; + +// // This is an internal Edge for the Scuffle service. +// // Used for communication between scuffle microservices. +// service Edge { +// // The transcoder service will call this method to send a segment to the +// edge. +// // Edge will then use this segment to create a new HLS variant. +// rpc PushSegment(PushSegmentRequest) returns (PushSegmentResponse) {} +// } + +// message PushSegmentRequest { +// // A stream ID can have multiple variants. +// string stream_id = 1; +// // The variant ID is the name of the variant. +// string variant_id = 2; +// // When this flag is set the edge can if it wants create a new segment with +// // this as the first part. +// bool can_be_new_segment = 3; +// // Duration of the segment in milliseconds. +// int32 duration = 4; +// // The raw segment data. +// bytes data = 5; +// } + +// message SegmentResponse {} diff --git a/proto/scuffle/video/ingest.proto b/proto/scuffle/video/ingest.proto new file mode 100644 index 00000000..74498b72 --- /dev/null +++ b/proto/scuffle/video/ingest.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; + +package scuffle.video; + +// This is an internal Ingest for the Scuffle service. +// Used for communication between scuffle microservices. +service Ingest { + // WatchStream is a streaming RPC that allows the transcoder to watch a video + // stream. Used by the Scuffle transcoder to digest the video stream and then + // transcode it Pushing it to the Edge service. + rpc WatchStream(WatchStreamRequest) returns (stream WatchStreamResponse) {} + + /// TranscoderEvent is a RPC that allows the transcoder to send events to the + // Ingest service. + rpc TranscoderEvent(TranscoderEventRequest) + returns (TranscoderEventResponse) {} +} + +message WatchStreamRequest { + // The uuid of the request that was queued by the Ingest service. + // This is not the publish uuid. This is to make sure we can easily revoke + // queued requests. + string request_id = 1; + + // Stream id of the stream that is being transcoded. + string stream_id = 2; +} + +message WatchStreamResponse { + message MediaSegment { + enum DataType { + // The fragment is a video fragment. + VIDEO = 0; + // The fragment is an audio fragment. + AUDIO = 1; + } + + // The fragment number. + uint64 timestamp = 1; + // The fragment data. + bytes data = 2; + // Keyframe information. + bool keyframe = 3; + // Type of the fragment. + DataType data_type = 4; + } + + oneof data { + bytes init_segment = 1; + MediaSegment media_segment = 2; + // If this is true, the stream has ended, if this is false, the transcoder + // session has ended (and the stream is still going). + bool shutting_down = 3; + } +} + +message TranscoderEventRequest { + // The uuid of the request that was queued by the Ingest service. + string request_id = 1; + + // Stream id of the stream that is being transcoded. + string stream_id = 2; + + message Error { + // The error message. + string message = 1; + // The error code. + bool fatal = 2; + } + + oneof event { + bool started = 3; + bool shutting_down = 4; + Error error = 5; + } +} + +message TranscoderEventResponse {} diff --git a/proto/scuffle/video/transcoder.proto b/proto/scuffle/video/transcoder.proto new file mode 100644 index 00000000..93b0a49c --- /dev/null +++ b/proto/scuffle/video/transcoder.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package scuffle.video; + +// This is an internal Transcoder for the Scuffle service. +// Used for communication between scuffle microservices. +service Transcoder {} diff --git a/schema.graphql b/schema.graphql index bdc36a87..d3313cf6 100644 --- a/schema.graphql +++ b/schema.graphql @@ -35,6 +35,16 @@ type AuthMutation { scalar DateRFC3339 +type GlobalRole { + allowedPermissions: Int! + createdAt: DateRFC3339! + deniedPermissions: Int! + description: String! + id: UUID! + name: String! + rank: Int! +} + """ The root mutation type which contains root level fields. """ @@ -47,6 +57,7 @@ The root query type which contains root level fields. """ type Query { noop: Boolean! + userById(id: UUID!): User userByUsername(username: String!): User } @@ -62,7 +73,7 @@ type Session { """ The session's id """ - id: Int! + id: UUID! """ Last used at """ @@ -75,19 +86,35 @@ type Session { """ The user who owns this session """ - userId: Int! + userId: UUID! } type Subscription { noop: Boolean! } +""" +A UUID is a unique 128-bit number, stored as 16 octets. UUIDs are parsed as +Strings within GraphQL. UUIDs are used to assign unique identifiers to +entities without requiring a central allocating authority. + +# References + +* [Wikipedia: Universally Unique Identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier) +* [RFC4122: A Universally Unique IDentifier (UUID) URN Namespace](http://tools.ietf.org/html/rfc4122) +""" +scalar UUID @specifiedBy(url: "http://tools.ietf.org/html/rfc4122") + type User { createdAt: DateRFC3339! + displayName: String! email: String! emailVerified: Boolean! - id: Int! + globalRoles: [GlobalRole!]! + id: UUID! lastLoginAt: DateRFC3339! + permissions: Int! + streamKey: String! username: String! } diff --git a/video/assets/av1_aac.flv b/video/assets/av1_aac.flv new file mode 100644 index 00000000..b6cf5fdf Binary files /dev/null and b/video/assets/av1_aac.flv differ diff --git a/video/assets/av1_aac_fragmented.mp4 b/video/assets/av1_aac_fragmented.mp4 new file mode 100644 index 00000000..3346fd23 Binary files /dev/null and b/video/assets/av1_aac_fragmented.mp4 differ diff --git a/video/assets/avc_aac.flv b/video/assets/avc_aac.flv new file mode 100644 index 00000000..220ec52a Binary files /dev/null and b/video/assets/avc_aac.flv differ diff --git a/video/assets/avc_aac.mp4 b/video/assets/avc_aac.mp4 new file mode 100644 index 00000000..e55b27fb Binary files /dev/null and b/video/assets/avc_aac.mp4 differ diff --git a/video/assets/avc_aac_fragmented.mp4 b/video/assets/avc_aac_fragmented.mp4 new file mode 100644 index 00000000..b74ab85a Binary files /dev/null and b/video/assets/avc_aac_fragmented.mp4 differ diff --git a/video/assets/avc_aac_keyframes.mp4 b/video/assets/avc_aac_keyframes.mp4 new file mode 100644 index 00000000..8ec365c9 Binary files /dev/null and b/video/assets/avc_aac_keyframes.mp4 differ diff --git a/video/assets/avc_aac_large.mp4 b/video/assets/avc_aac_large.mp4 new file mode 100644 index 00000000..16fcf40e Binary files /dev/null and b/video/assets/avc_aac_large.mp4 differ diff --git a/video/assets/hevc_aac.flv b/video/assets/hevc_aac.flv new file mode 100644 index 00000000..08495b5e Binary files /dev/null and b/video/assets/hevc_aac.flv differ diff --git a/video/assets/hevc_aac_fragmented.mp4 b/video/assets/hevc_aac_fragmented.mp4 new file mode 100644 index 00000000..2feb9243 Binary files /dev/null and b/video/assets/hevc_aac_fragmented.mp4 differ diff --git a/video/bytesio/Cargo.toml b/video/bytesio/Cargo.toml new file mode 100644 index 00000000..7bfd30c9 --- /dev/null +++ b/video/bytesio/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bytesio" +version = "0.0.1" +edition = "2021" + +[features] +tokio = ["dep:tokio-util", "dep:tokio-stream", "dep:tokio", "dep:futures", "dep:common"] +default = ["tokio"] + +[dependencies] +byteorder = "1" +bytes = "1" + +futures = { version = "0", optional = true } +tokio-util = { version = "0", features = ["codec"], optional = true } +tokio-stream = { version = "0", optional = true } +tokio = { version = "1", optional = true } +common = { path = "../../common", default-features = false, features = ["prelude"], optional = true } + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/video/bytesio/src/bit_reader.rs b/video/bytesio/src/bit_reader.rs new file mode 100644 index 00000000..655602a0 --- /dev/null +++ b/video/bytesio/src/bit_reader.rs @@ -0,0 +1,179 @@ +use std::io::{self, SeekFrom}; + +use byteorder::ReadBytesExt; +use bytes::{Buf, Bytes}; + +pub struct BitReader> { + data: T, + bit_pos: usize, + current_byte: u8, +} + +impl> From for BitReader> { + fn from(bytes: T) -> Self { + Self::new(io::Cursor::new(bytes.into())) + } +} + +impl BitReader { + pub fn seek_bits(&mut self, pos: i64) -> io::Result<()> { + let mut seek_pos = self.data.stream_position()? as i64; + if !self.is_aligned() && seek_pos > 0 { + seek_pos -= 1; + } + + let current_bit_pos = self.bit_pos as i64; + let new_tb_pos = current_bit_pos + pos + seek_pos * 8; + if new_tb_pos < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot seek to a negative position", + )); + } + + self.seek_to(new_tb_pos as u64)?; + + Ok(()) + } + + pub fn current_byte_bit_pos(&mut self) -> io::Result { + let position = self.data.stream_position()?; + if position == 0 { + return Ok(0); + } + + if self.is_aligned() { + Ok(position * 8) + } else { + Ok(position * 8 - 8 + self.bit_pos as u64) + } + } + + pub fn seek_to(&mut self, pos: u64) -> io::Result<()> { + self.data.seek(SeekFrom::Start(pos / 8))?; + + self.bit_pos = (pos % 8) as usize; + if self.bit_pos != 0 { + self.current_byte = self.data.read_u8()?; + } + + Ok(()) + } +} + +impl BitReader { + pub fn new(data: T) -> Self { + Self { + data, + bit_pos: 0, + current_byte: 0, + } + } + + pub fn read_bit(&mut self) -> io::Result { + if self.is_aligned() { + self.current_byte = self.data.read_u8()?; + } + + let bit = (self.current_byte >> (7 - self.bit_pos)) & 1; + + self.bit_pos += 1; + self.bit_pos %= 8; + + Ok(bit == 1) + } + + pub fn read_bits(&mut self, count: u8) -> io::Result { + let mut bits = 0; + for _ in 0..count { + let bit = self.read_bit()?; + bits <<= 1; + bits |= bit as u64; + } + + Ok(bits) + } + + pub fn into_inner(self) -> T { + self.data + } + + pub fn get_ref(&self) -> &T { + &self.data + } + + pub fn get_mut(&mut self) -> &mut T { + &mut self.data + } + + pub fn get_bit_pos(&self) -> usize { + self.bit_pos + } + + pub fn align(&mut self) -> io::Result<()> { + let amount_to_read = 8 - self.bit_pos; + self.read_bits(amount_to_read as u8)?; + Ok(()) + } + + pub fn is_aligned(&self) -> bool { + self.bit_pos == 0 + } +} + +impl io::Read for BitReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.is_aligned() { + return self.data.read(buf); + } + + let mut read = 0; + for b in buf { + let mut byte = 0; + for _ in 0..8 { + let bit = self.read_bit()?; + byte <<= 1; + byte |= bit as u8; + } + *b = byte; + read += 1; + } + + Ok(read) + } +} + +impl> BitReader> { + pub fn is_empty(&self) -> bool { + self.data.position() as usize == self.data.get_ref().as_ref().len() + } + + pub fn remaining_bits(&self) -> usize { + let remaining = self.data.remaining(); + + if self.is_aligned() { + remaining * 8 + } else { + remaining * 8 + 8 - self.bit_pos + } + } +} + +impl io::Seek for BitReader { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + match pos { + io::SeekFrom::Start(pos) => { + self.seek_to(pos * 8)?; + } + io::SeekFrom::Current(pos) => { + self.seek_bits(pos * 8)?; + } + io::SeekFrom::End(pos) => { + let end = self.data.seek(io::SeekFrom::End(0))? as i64; + self.seek_to((end + pos) as u64 * 8)?; + } + } + + self.data.stream_position() + } +} diff --git a/video/bytesio/src/bit_writer.rs b/video/bytesio/src/bit_writer.rs new file mode 100644 index 00000000..5dcb2bb6 --- /dev/null +++ b/video/bytesio/src/bit_writer.rs @@ -0,0 +1,114 @@ +use std::io; + +#[derive(Default, Clone, Debug)] +pub struct BitWriter { + data: Vec, + bit_pos: usize, +} + +impl BitWriter { + pub fn write_bit(&mut self, bit: bool) -> io::Result<()> { + let byte_pos = self.bit_pos / 8; + let bit_pos = self.bit_pos % 8; + + if byte_pos >= self.data.len() { + self.data.push(0); + } + + let byte = &mut self.data[byte_pos]; + if bit { + *byte |= 1 << (7 - bit_pos); + } else { + *byte &= !(1 << (7 - bit_pos)); + } + + self.bit_pos += 1; + + Ok(()) + } + + pub fn write_bits(&mut self, bits: u64, count: usize) -> io::Result<()> { + for i in 0..count { + let bit = (bits >> (count - i - 1)) & 1 == 1; + self.write_bit(bit)?; + } + + Ok(()) + } + + pub fn into_inner(self) -> Vec { + self.data + } + + pub fn get_ref(&self) -> &Vec { + &self.data + } + + pub fn get_mut(&mut self) -> &mut Vec { + &mut self.data + } + + pub fn get_bit_pos(&self) -> usize { + self.bit_pos + } + + pub fn is_aligned(&self) -> bool { + self.bit_pos % 8 == 0 + } + + pub fn align(&mut self) -> io::Result<()> { + if !self.is_aligned() { + self.write_bits(0, 8 - (self.bit_pos % 8))?; + } + + Ok(()) + } + + pub fn seek_bits(&mut self, count: i64) { + if count < 0 { + if self.bit_pos < (-count) as usize { + self.bit_pos = 0; + } else { + self.bit_pos -= (-count) as usize; + } + } else if self.bit_pos + count as usize >= self.data.len() * 8 { + self.bit_pos = self.data.len() * 8; + } else { + self.bit_pos += count as usize; + } + } + + pub fn seek_to(&mut self, pos: usize) { + if pos >= self.data.len() * 8 { + self.bit_pos = self.data.len() * 8; + } else { + self.bit_pos = pos; + } + } +} + +impl io::Write for BitWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + for b in buf { + self.write_bits(*b as u64, 8)?; + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl io::Seek for BitWriter { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + match pos { + io::SeekFrom::Start(pos) => self.seek_to((pos * 8) as usize), + io::SeekFrom::Current(pos) => self.seek_bits(pos * 8), + io::SeekFrom::End(pos) => self.seek_to((self.data.len() as i64 + pos) as usize * 8), + }; + + Ok(self.bit_pos as u64) + } +} diff --git a/video/bytesio/src/bytes_reader.rs b/video/bytesio/src/bytes_reader.rs new file mode 100644 index 00000000..5723ebb7 --- /dev/null +++ b/video/bytesio/src/bytes_reader.rs @@ -0,0 +1,106 @@ +use std::io; + +use bytes::{Bytes, BytesMut}; + +pub struct BytesReader { + buffer: BytesMut, +} + +impl BytesReader { + pub fn new(buffer: BytesMut) -> Self { + Self { buffer } + } + + pub fn extend_from_slice(&mut self, extend: &[u8]) { + self.buffer.extend_from_slice(extend) + } + + pub fn read_bytes(&mut self, bytes_num: usize) -> io::Result { + if self.buffer.len() < bytes_num { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Not enough bytes", + )) + } else { + Ok(self.buffer.split_to(bytes_num)) + } + } + + pub fn advance_bytes(&'_ self, bytes_num: usize) -> io::Result<&'_ [u8]> { + if self.buffer.len() < bytes_num { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Not enough bytes", + )) + } else { + Ok(self.buffer[..bytes_num].as_ref()) + } + } + + pub fn advance_bytes_cursor(&'_ self, bytes_num: usize) -> io::Result> { + Ok(io::Cursor::new(self.advance_bytes(bytes_num)?)) + } + + pub fn get(&self, index: usize) -> io::Result { + if index >= self.len() { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Not enough bytes", + )) + } else { + Ok(*self.buffer.get(index).unwrap()) + } + } + + pub fn len(&self) -> usize { + self.buffer.len() + } + + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + pub fn extract_remaining_bytes(&mut self) -> BytesMut { + self.buffer.split_to(self.buffer.len()) + } + + pub fn get_remaining_bytes(&self) -> BytesMut { + self.buffer.clone() + } +} + +impl io::Read for BytesReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let amount = std::cmp::min(buf.len(), self.buffer.len()); + let remaining = self.read_bytes(amount)?; + buf[..amount].copy_from_slice(&remaining[..amount]); + Ok(amount) + } +} + +pub trait BytesCursor { + fn get_remaining(&self) -> Bytes; + fn read_slice(&mut self, size: usize) -> io::Result; +} + +impl BytesCursor for io::Cursor { + fn get_remaining(&self) -> Bytes { + let position = self.position() as usize; + self.get_ref().slice(position..) + } + + fn read_slice(&mut self, size: usize) -> io::Result { + let position = self.position() as usize; + if position + size > self.get_ref().len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "not enough bytes", + )); + } + + let slice = self.get_ref().slice(position..position + size); + self.set_position((position + size) as u64); + + Ok(slice) + } +} diff --git a/video/bytesio/src/bytes_writer.rs b/video/bytesio/src/bytes_writer.rs new file mode 100644 index 00000000..b3cca947 --- /dev/null +++ b/video/bytesio/src/bytes_writer.rs @@ -0,0 +1,38 @@ +use bytes::{Bytes, BytesMut}; +use std::io; + +#[derive(Default)] +pub struct BytesWriter { + bytes: Vec, +} + +impl BytesWriter { + pub fn extract_current_bytes(&mut self) -> BytesMut { + let mut rv_data = BytesMut::new(); + rv_data.extend_from_slice(&self.bytes.clone()[..]); + self.bytes.clear(); + + rv_data + } + + pub fn get_current_bytes(&mut self) -> BytesMut { + let mut rv_data = BytesMut::new(); + rv_data.extend_from_slice(&self.bytes[..]); + + rv_data + } + + pub fn dispose(self) -> Bytes { + self.bytes.into() + } +} + +impl io::Write for BytesWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.bytes.flush() + } +} diff --git a/video/bytesio/src/bytesio.rs b/video/bytesio/src/bytesio.rs new file mode 100644 index 00000000..a3840892 --- /dev/null +++ b/video/bytesio/src/bytesio.rs @@ -0,0 +1,67 @@ +use super::bytesio_errors::BytesIOError; +use bytes::{Bytes, BytesMut}; +use common::prelude::FutureTimeout; +use futures::SinkExt; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_stream::StreamExt; +use tokio_util::codec::{BytesCodec, Framed}; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Unpin + Send + Sync {} + +impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite + Unpin + Send + Sync {} + +pub struct BytesIO { + stream: Framed, +} + +impl BytesIO { + pub fn new(stream: S) -> Self { + Self { + stream: Framed::new(stream, BytesCodec::new()), + } + } + + pub fn with_capacity(stream: S, capacity: usize) -> Self { + Self { + stream: Framed::with_capacity(stream, BytesCodec::new(), capacity), + } + } + + pub async fn write(&mut self, bytes: Bytes) -> Result<(), BytesIOError> { + self.stream + .send(bytes) + .await + .map_err(|_| BytesIOError::ClientClosed)?; + + Ok(()) + } + + pub async fn read(&mut self) -> Result { + let Some(Ok(message)) = self.stream.next().await else { + return Err(BytesIOError::ClientClosed); + }; + + Ok(message) + } + + pub async fn read_timeout(&mut self, timeout: Duration) -> Result { + self.read() + .timeout(timeout) + .await? + .map_err(|_| BytesIOError::ClientClosed) + } + + pub async fn write_timeout( + &mut self, + bytes: Bytes, + timeout: Duration, + ) -> Result<(), BytesIOError> { + self.write(bytes) + .timeout(timeout) + .await? + .map_err(|_| BytesIOError::ClientClosed)?; + + Ok(()) + } +} diff --git a/video/bytesio/src/bytesio_errors.rs b/video/bytesio/src/bytesio_errors.rs new file mode 100644 index 00000000..81ed2c49 --- /dev/null +++ b/video/bytesio/src/bytesio_errors.rs @@ -0,0 +1,24 @@ +use std::fmt; + +use tokio::time::error::Elapsed; + +#[derive(Debug)] +pub enum BytesIOError { + Timeout, + ClientClosed, +} + +impl From for BytesIOError { + fn from(_error: tokio::time::error::Elapsed) -> Self { + Self::Timeout + } +} + +impl fmt::Display for BytesIOError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Timeout => write!(f, "timeout"), + Self::ClientClosed => write!(f, "client closed"), + } + } +} diff --git a/video/bytesio/src/lib.rs b/video/bytesio/src/lib.rs new file mode 100644 index 00000000..f5863290 --- /dev/null +++ b/video/bytesio/src/lib.rs @@ -0,0 +1,12 @@ +pub mod bit_reader; +pub mod bit_writer; +pub mod bytes_reader; +pub mod bytes_writer; + +#[cfg(feature = "tokio")] +pub mod bytesio; +#[cfg(feature = "tokio")] +pub mod bytesio_errors; + +#[cfg(test)] +mod tests; diff --git a/video/bytesio/src/tests/bit_reader.rs b/video/bytesio/src/tests/bit_reader.rs new file mode 100644 index 00000000..8c5d9f4e --- /dev/null +++ b/video/bytesio/src/tests/bit_reader.rs @@ -0,0 +1,92 @@ +use std::io::{Read, Seek, SeekFrom}; + +use byteorder::ReadBytesExt; +use bytes::Bytes; + +use crate::bit_reader::BitReader; + +#[test] +fn test_bit_reader() { + let data = Bytes::from(vec![0b10110111, 0b01011000]); + + let mut reader = BitReader::from(data); + + assert!(reader.read_bit().unwrap()); + assert!(!reader.read_bit().unwrap()); + assert!(reader.read_bit().unwrap()); + assert!(reader.read_bit().unwrap()); + + assert_eq!(reader.read_bits(3).unwrap(), 0b011); + assert_eq!(reader.read_bits(8).unwrap(), 0b10101100); + assert_eq!(reader.read_bits(1).unwrap(), 0b0); + + assert!(reader.is_empty()); +} + +#[test] +fn test_bit_reader_read() { + let data = Bytes::from(vec![0b10110111, 0b01011000, 0b11111111]); + + let mut reader = BitReader::from(data); + + reader.seek_bits(1).unwrap(); + + let mut buf = [0u8; 2]; + assert_eq!(reader.read(&mut buf).unwrap(), 2); + assert_eq!(buf, [0b01101110, 0b10110001]); +} + +#[test] +fn test_bit_reader_read_ext() { + let data = Bytes::from(vec![0b10110111, 0b01011000, 0b11111111]); + + let mut reader = BitReader::from(data); + + reader.seek_bits(1).unwrap(); + + assert_eq!(reader.get_bit_pos(), 1); + + reader.seek_bits(3).unwrap(); + + assert_eq!(reader.get_bit_pos(), 4); + + let mut buf = [0u8; 2]; + assert_eq!(reader.read(&mut buf).unwrap(), 2); + assert_eq!(buf, [0b01110101, 0b10001111]); +} + +#[test] +fn test_bit_reader_seek() { + let data = Bytes::from(vec![0b10110111, 0b01011000, 0b11111111]); + + let mut reader = BitReader::from(data); + + reader.seek(SeekFrom::Start(1)).unwrap(); + + assert!(!reader.read_bit().unwrap()); + assert!(reader.read_bit().unwrap()); + assert!(!reader.read_bit().unwrap()); + + reader.seek_to(3).unwrap(); + reader.seek(SeekFrom::Current(1)).unwrap(); + + assert_eq!(reader.read_u8().unwrap(), 0b11000111); + + reader.seek(SeekFrom::End(-1)).unwrap(); + + assert_eq!(reader.read_u8().unwrap(), 0b11111111); +} + +#[test] +fn test_bit_reader_align() { + let data = Bytes::from(vec![0b10110111, 0b01011000, 0b11111111]); + + let mut reader = BitReader::from(data); + + reader.seek_bits(1).unwrap(); + + reader.align().unwrap(); + + assert!(reader.is_aligned()); + assert_eq!(reader.get_bit_pos(), 0); +} diff --git a/video/bytesio/src/tests/bit_writer.rs b/video/bytesio/src/tests/bit_writer.rs new file mode 100644 index 00000000..8f96219f --- /dev/null +++ b/video/bytesio/src/tests/bit_writer.rs @@ -0,0 +1,133 @@ +use std::io::{Seek, SeekFrom, Write}; + +use crate::bit_writer::BitWriter; + +#[test] +fn test_bit_writer() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bits(0b1, 1).unwrap(); // 1 + bit_writer.write_bits(0b010, 3).unwrap(); // 4 + bit_writer.write_bits(0b011, 3).unwrap(); // 7 + bit_writer.write_bits(0b00100, 5).unwrap(); // 12 + bit_writer.write_bits(0b00101, 5).unwrap(); // 17 + + let data = bit_writer.get_ref(); + + // 2 bytes + 1 bit + assert_eq!(data, &[0b10100110, 0b01000010, 0b10000000]); + + assert!(!bit_writer.is_aligned()); + + bit_writer.write_bits(0b1111000, 7).unwrap(); // 24 + + let data = bit_writer.get_ref(); + + // 3 bytes + assert_eq!(data, &[0b10100110, 0b01000010, 0b11111000]); + + assert!(bit_writer.is_aligned()); + + bit_writer.write_bits(0b1111000, 7).unwrap(); // 31 + + bit_writer.align().unwrap(); // 32 + + let data = bit_writer.get_ref(); + + // 4 bytes + assert_eq!(data, &[0b10100110, 0b01000010, 0b11111000, 0b11110000]); + + assert!(bit_writer.is_aligned()); + + bit_writer.write_bits(0b1, 1).unwrap(); // 33 + + let data = bit_writer.get_ref(); + + // 5 bytes + assert_eq!( + data, + &[0b10100110, 0b01000010, 0b11111000, 0b11110000, 0b10000000] + ); +} + +#[test] +fn test_bit_writer_write() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bit(true).unwrap(); // 1 + bit_writer + .write_all(&[0b00000001, 0b00000010, 0b00000011, 0b00000100]) + .unwrap(); // 33 + + let data = bit_writer.get_ref(); + + // 5 bytes + assert_eq!( + data, + &[0b10000000, 0b10000001, 0b00000001, 0b10000010, 0b0,] + ); +} + +#[test] +fn test_bit_writer_write_aligned() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bit(true).unwrap(); // 1 + bit_writer.align().unwrap(); // 8 + bit_writer + .write_all(&[0b00000001, 0b00000010, 0b00000011, 0b00000100]) + .unwrap(); // 40 + + let data = bit_writer.get_ref(); + + // 5 bytes + assert_eq!( + data, + &[0b10000000, 0b00000001, 0b00000010, 0b00000011, 0b00000100,] + ); +} + +#[test] +fn test_bit_writer_seek() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bits(0b1, 1).unwrap(); // 1 + bit_writer.write_bits(0b010, 3).unwrap(); // 4 + bit_writer.write_bits(0b011, 3).unwrap(); // 7 + bit_writer.write_bits(0b0, 1).unwrap(); // 8 + + let data = bit_writer.get_ref(); + + // 1 byte + assert_eq!(data, &[0b10100110]); + + bit_writer.seek(SeekFrom::Start(0)).unwrap(); + + bit_writer.write_bits(0b1, 1).unwrap(); // 1 + bit_writer.write_bits(0b111, 3).unwrap(); // 4 + bit_writer.write_bits(0b111, 3).unwrap(); // 7 + bit_writer.write_bits(0b10100, 5).unwrap(); // 12 + + let data = bit_writer.get_ref(); + + // 1 bytes + 4 bits + assert_eq!(data, &[0b11111111, 0b01000000]); + + bit_writer.seek_bits(-5); + + bit_writer.write_bits(0b0, 1).unwrap(); + + let data = bit_writer.get_ref(); + + // 1 bytes + 4 bits + assert_eq!(data, &[0b11111110, 0b01000000]); + + bit_writer.seek_to(4); + + bit_writer.write_bits(0b0, 1).unwrap(); + + let data = bit_writer.get_ref(); + + // 1 bytes + 4 bits + assert_eq!(data, &[0b11110110, 0b01000000]); +} diff --git a/video/bytesio/src/tests/bytes_reader.rs b/video/bytesio/src/tests/bytes_reader.rs new file mode 100644 index 00000000..e2a83467 --- /dev/null +++ b/video/bytesio/src/tests/bytes_reader.rs @@ -0,0 +1,90 @@ +use byteorder::{BigEndian, LittleEndian, ReadBytesExt}; +use bytes::BytesMut; + +use crate::bytes_reader::BytesReader; + +#[test] +fn test_byte_reader() { + let mut reader = BytesReader::new(BytesMut::from(&b"hello world"[..])); + assert_eq!(reader.read_bytes(5).unwrap(), BytesMut::from(&b"hello"[..])); + assert_eq!(reader.read_bytes(5).unwrap(), BytesMut::from(&b" worl"[..])); + assert!(reader.read_bytes(5).is_err()); + assert_eq!(reader.read_bytes(1).unwrap(), BytesMut::from(&b"d"[..])); + + reader.extend_from_slice(&b"hello world"[..]); + + assert_eq!(reader.advance_bytes(5).unwrap(), &b"hello"[..]); + assert_eq!(reader.read_bytes(11).unwrap(), &b"hello world"[..]); + + assert!(reader.is_empty()); +} + +#[test] +fn test_read_binary() { + let binary: Vec = vec![54, 0, 15, 0, 255, 0, 255, 0]; + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u32::().unwrap(), 905973504); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u32::().unwrap(), 983094); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u24::().unwrap(), 3538959); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u24::().unwrap(), 983094); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u16::().unwrap(), 13824); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u16::().unwrap(), 54); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.read_u8().unwrap(), 54); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!( + reader.read_f64::().unwrap(), + 1.3734682653814624e-48 + ); + + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!( + reader.read_f64::().unwrap(), + 7.064161010106551e-304 + ); +} + +#[test] +fn test_get_index() { + let binary: Vec = vec![54, 0, 15, 0, 255, 0, 255, 0]; + let reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.get(0).unwrap(), 54); + assert_eq!(reader.get(1).unwrap(), 0); + assert_eq!(reader.get(2).unwrap(), 15); + assert_eq!(reader.get(3).unwrap(), 0); + assert_eq!(reader.get(4).unwrap(), 255); + assert_eq!(reader.get(5).unwrap(), 0); + assert_eq!(reader.get(6).unwrap(), 255); + assert_eq!(reader.get(7).unwrap(), 0); + assert!(reader.get(8).is_err()); + + assert_eq!(reader.len(), 8); +} + +#[test] +fn test_remaining() { + let binary: Vec = vec![54, 0, 15, 0, 255, 0, 255, 0]; + let mut reader = BytesReader::new(BytesMut::from(binary.as_slice())); + assert_eq!(reader.get_remaining_bytes(), &b"6\0\x0f\0\xff\0\xff\0"[..]); + + reader.read_bytes(4).unwrap(); + + assert_eq!(reader.get_remaining_bytes(), &b"\xff\0\xff\0"[..]); + + assert_eq!(reader.extract_remaining_bytes(), &b"\xff\0\xff\0"[..]); + + assert_eq!(reader.get_remaining_bytes(), &b""[..]); + assert_eq!(reader.len(), 0); +} diff --git a/video/bytesio/src/tests/bytes_writer.rs b/video/bytesio/src/tests/bytes_writer.rs new file mode 100644 index 00000000..5bb499d5 --- /dev/null +++ b/video/bytesio/src/tests/bytes_writer.rs @@ -0,0 +1,31 @@ +use std::io::Write; + +use byteorder::{BigEndian, LittleEndian, ReadBytesExt, WriteBytesExt}; + +use crate::{bytes_reader::BytesReader, bytes_writer::BytesWriter}; + +#[test] +fn test_byte_writer() { + let mut writer = BytesWriter::default(); + writer.write_u8(0x01).unwrap(); // 1 byte + writer.write_u16::(0x0203).unwrap(); // 2 bytes + writer.write_u24::(0x040506).unwrap(); // 3 bytes + writer.write_u32::(0x0708090a).unwrap(); // 4 bytes + writer.write_f64::(0.123456789).unwrap(); // 8 bytes + writer.write_all(&[0x0b, 0x0c, 0x0d, 0x0e, 0x0f]).unwrap(); // 5 bytes + + let bytes = writer.get_current_bytes(); + let mut reader = BytesReader::new(bytes); + assert_eq!(reader.read_u8().unwrap(), 0x01); + assert_eq!(reader.read_u16::().unwrap(), 0x0203); + assert_eq!(reader.read_u24::().unwrap(), 0x040506); + assert_eq!(reader.read_u32::().unwrap(), 0x0708090a); + assert_eq!(reader.read_f64::().unwrap(), 0.123456789); + assert_eq!( + reader.read_bytes(5).unwrap().to_vec(), + &[0x0b, 0x0c, 0x0d, 0x0e, 0x0f] + ); + + assert!(reader.is_empty()); + assert!(!writer.extract_current_bytes().is_empty()) +} diff --git a/video/bytesio/src/tests/bytesio.rs b/video/bytesio/src/tests/bytesio.rs new file mode 100644 index 00000000..17343740 --- /dev/null +++ b/video/bytesio/src/tests/bytesio.rs @@ -0,0 +1,24 @@ +use bytes::Bytes; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::bytesio::BytesIO; + +#[tokio::test] +async fn test_bytes_io() { + let (pipe1, mut pipe2) = tokio::io::duplex(1024); + let mut bytesio = BytesIO::new(Box::new(pipe1)); + + bytesio + .write(Bytes::from_static(b"hello world")) + .await + .unwrap(); + + let mut buf = vec![0; 11]; + pipe2.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, b"hello world".to_vec()); + + pipe2.write_all(b"hello bytesio").await.unwrap(); + + let buf = bytesio.read().await.unwrap(); + assert_eq!(buf.to_vec(), b"hello bytesio".to_vec()); +} diff --git a/video/bytesio/src/tests/errors.rs b/video/bytesio/src/tests/errors.rs new file mode 100644 index 00000000..68498d23 --- /dev/null +++ b/video/bytesio/src/tests/errors.rs @@ -0,0 +1,21 @@ +use crate::bytesio_errors::BytesIOError; + +#[tokio::test] +async fn test_timeout_error_display() { + let err = tokio::time::timeout(std::time::Duration::from_millis(100), async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + }) + .await + .unwrap_err(); + let bytes_io_error = BytesIOError::from(err); + assert_eq!(bytes_io_error.to_string(), "timeout"); + + let bytes_io_error = BytesIOError::ClientClosed; + assert_eq!(bytes_io_error.to_string(), "client closed"); +} + +#[test] +fn test_bytesio_error_display() { + let bytes_io_error = BytesIOError::ClientClosed; + assert_eq!(bytes_io_error.to_string(), "client closed"); +} diff --git a/video/bytesio/src/tests/mod.rs b/video/bytesio/src/tests/mod.rs new file mode 100644 index 00000000..d58f8c5f --- /dev/null +++ b/video/bytesio/src/tests/mod.rs @@ -0,0 +1,6 @@ +mod bit_reader; +mod bit_writer; +mod bytes_reader; +mod bytes_writer; +mod bytesio; +mod errors; diff --git a/video/codec/aac/Cargo.toml b/video/codec/aac/Cargo.toml new file mode 100644 index 00000000..df931e44 --- /dev/null +++ b/video/codec/aac/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "aac" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +byteorder = "1" +num-traits = "0" +num-derive = "0" +bytesio = { path = "../../bytesio", default-features = false } diff --git a/video/codec/aac/src/config.rs b/video/codec/aac/src/config.rs new file mode 100644 index 00000000..bcb4d51c --- /dev/null +++ b/video/codec/aac/src/config.rs @@ -0,0 +1,122 @@ +use std::io; + +use bytes::Bytes; +use bytesio::bit_reader::BitReader; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; + +#[derive(Debug, Clone, PartialEq)] +/// Audio Specific Config +/// ISO/IEC 14496-3:2019(E) - 1.6 +pub struct AudioSpecificConfig { + pub audio_object_type: AudioObjectType, + pub sampling_frequency: u32, + pub channel_configuration: u8, + pub data: Bytes, +} + +#[derive(Debug, Clone, PartialEq, Copy, Eq)] +/// SBR Audio Object Type +/// ISO/IEC 14496-3:2019(E) - 1.5.1.2.6 +pub enum AudioObjectType { + AacMain, + AacLowComplexity, + Unknown(u16), +} + +impl From for AudioObjectType { + fn from(value: u16) -> Self { + match value { + 1 => AudioObjectType::AacMain, + 2 => AudioObjectType::AacLowComplexity, + _ => AudioObjectType::Unknown(value), + } + } +} + +impl From for u16 { + fn from(value: AudioObjectType) -> Self { + match value { + AudioObjectType::AacMain => 1, + AudioObjectType::AacLowComplexity => 2, + AudioObjectType::Unknown(value) => value, + } + } +} + +#[derive(FromPrimitive)] +#[repr(u8)] +/// Sampling Frequency Index +/// ISO/IEC 14496-3:2019(E) - 1.6.2.4 (Table 1.22) +pub enum SampleFrequencyIndex { + Freq96000 = 0x0, + Freq88200 = 0x1, + Freq64000 = 0x2, + Freq48000 = 0x3, + Freq44100 = 0x4, + Freq32000 = 0x5, + Freq24000 = 0x6, + Freq22050 = 0x7, + Freq16000 = 0x8, + Freq12000 = 0x9, + Freq11025 = 0xA, + Freq8000 = 0xB, + Freq7350 = 0xC, + FreqReserved = 0xD, + FreqReserved2 = 0xE, + FreqEscape = 0xF, +} + +impl SampleFrequencyIndex { + pub fn to_freq(&self) -> u32 { + match self { + SampleFrequencyIndex::Freq96000 => 96000, + SampleFrequencyIndex::Freq88200 => 88200, + SampleFrequencyIndex::Freq64000 => 64000, + SampleFrequencyIndex::Freq48000 => 48000, + SampleFrequencyIndex::Freq44100 => 44100, + SampleFrequencyIndex::Freq32000 => 32000, + SampleFrequencyIndex::Freq24000 => 24000, + SampleFrequencyIndex::Freq22050 => 22050, + SampleFrequencyIndex::Freq16000 => 16000, + SampleFrequencyIndex::Freq12000 => 12000, + SampleFrequencyIndex::Freq11025 => 11025, + SampleFrequencyIndex::Freq8000 => 8000, + SampleFrequencyIndex::Freq7350 => 7350, + SampleFrequencyIndex::FreqReserved => 0, + SampleFrequencyIndex::FreqReserved2 => 0, + SampleFrequencyIndex::FreqEscape => 0, + } + } +} + +impl AudioSpecificConfig { + pub fn parse(data: Bytes) -> io::Result { + let mut bitreader = BitReader::from(data); + let mut audio_object_type = bitreader.read_bits(5)? as u16; + if audio_object_type == 31 { + audio_object_type = 32 + bitreader.read_bits(6)? as u16; + } + + let sampling_frequency_index = SampleFrequencyIndex::from_u8(bitreader.read_bits(4)? as u8) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Invalid sampling frequency index", + ) + })?; + let sampling_frequency = match sampling_frequency_index { + SampleFrequencyIndex::FreqEscape => bitreader.read_bits(24)? as u32, + _ => sampling_frequency_index.to_freq(), + }; + + let channel_configuration = bitreader.read_bits(4)? as u8; + + Ok(Self { + audio_object_type: audio_object_type.into(), + sampling_frequency, + channel_configuration, + data: bitreader.into_inner().into_inner(), + }) + } +} diff --git a/video/codec/aac/src/lib.rs b/video/codec/aac/src/lib.rs new file mode 100644 index 00000000..bc1bd5f5 --- /dev/null +++ b/video/codec/aac/src/lib.rs @@ -0,0 +1,6 @@ +mod config; + +pub use config::{AudioObjectType, AudioSpecificConfig, SampleFrequencyIndex}; + +#[cfg(test)] +mod tests; diff --git a/video/codec/aac/src/tests.rs b/video/codec/aac/src/tests.rs new file mode 100644 index 00000000..c81c41e1 --- /dev/null +++ b/video/codec/aac/src/tests.rs @@ -0,0 +1,35 @@ +use bytes::Bytes; + +use crate::{config::SampleFrequencyIndex, AudioObjectType, AudioSpecificConfig}; + +#[test] +fn test_aac_config_parse() { + let data = vec![ + 0x12, 0x10, 0x56, 0xe5, 0x00, 0x2d, 0x96, 0x01, 0x80, 0x80, 0x05, 0x00, 0x00, 0x00, 0x00, + ]; + + let config = AudioSpecificConfig::parse(Bytes::from(data)).unwrap(); + assert_eq!(config.audio_object_type, AudioObjectType::AacLowComplexity); + assert_eq!(config.sampling_frequency, 44100); + assert_eq!(config.channel_configuration, 2); +} + +#[test] +fn test_idx_to_freq() { + assert_eq!(0, SampleFrequencyIndex::FreqEscape.to_freq()); + assert_eq!(0, SampleFrequencyIndex::FreqReserved2.to_freq()); + assert_eq!(0, SampleFrequencyIndex::FreqReserved.to_freq()); + assert_eq!(7350, SampleFrequencyIndex::Freq7350.to_freq()); + assert_eq!(8000, SampleFrequencyIndex::Freq8000.to_freq()); + assert_eq!(11025, SampleFrequencyIndex::Freq11025.to_freq()); + assert_eq!(12000, SampleFrequencyIndex::Freq12000.to_freq()); + assert_eq!(16000, SampleFrequencyIndex::Freq16000.to_freq()); + assert_eq!(22050, SampleFrequencyIndex::Freq22050.to_freq()); + assert_eq!(24000, SampleFrequencyIndex::Freq24000.to_freq()); + assert_eq!(32000, SampleFrequencyIndex::Freq32000.to_freq()); + assert_eq!(44100, SampleFrequencyIndex::Freq44100.to_freq()); + assert_eq!(48000, SampleFrequencyIndex::Freq48000.to_freq()); + assert_eq!(64000, SampleFrequencyIndex::Freq64000.to_freq()); + assert_eq!(88200, SampleFrequencyIndex::Freq88200.to_freq()); + assert_eq!(96000, SampleFrequencyIndex::Freq96000.to_freq()); +} diff --git a/video/codec/av1/Cargo.toml b/video/codec/av1/Cargo.toml new file mode 100644 index 00000000..2c938011 --- /dev/null +++ b/video/codec/av1/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "av1" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +byteorder = "1" +bytesio = { path = "../../bytesio", default-features = false } diff --git a/video/codec/av1/src/config.rs b/video/codec/av1/src/config.rs new file mode 100644 index 00000000..2a5f72d8 --- /dev/null +++ b/video/codec/av1/src/config.rs @@ -0,0 +1,120 @@ +use std::io; + +use bytes::Bytes; +use bytesio::{bit_reader::BitReader, bit_writer::BitWriter, bytes_reader::BytesCursor}; + +#[derive(Debug, Clone, PartialEq)] +/// AV1 Codec Configuration Record +/// https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-syntax +pub struct AV1CodecConfigurationRecord { + pub marker: bool, + pub version: u8, + pub seq_profile: u8, + pub seq_level_idx_0: u8, + pub seq_tier_0: bool, + pub high_bitdepth: bool, + pub twelve_bit: bool, + pub monochrome: bool, + pub chroma_subsampling_x: bool, + pub chroma_subsampling_y: bool, + pub chroma_sample_position: u8, + pub initial_presentation_delay_minus_one: Option, + pub config_obu: Bytes, +} + +impl AV1CodecConfigurationRecord { + pub fn demux(reader: &mut io::Cursor) -> io::Result { + let mut bit_reader = BitReader::new(reader); + + let marker = bit_reader.read_bit()?; + let version = bit_reader.read_bits(7)? as u8; + + let seq_profile = bit_reader.read_bits(3)? as u8; + let seq_level_idx_0 = bit_reader.read_bits(5)? as u8; + + let seq_tier_0 = bit_reader.read_bit()?; + let high_bitdepth = bit_reader.read_bit()?; + let twelve_bit = bit_reader.read_bit()?; + let monochrome = bit_reader.read_bit()?; + let chroma_subsampling_x = bit_reader.read_bit()?; + let chroma_subsampling_y = bit_reader.read_bit()?; + let chroma_sample_position = bit_reader.read_bits(2)? as u8; + + bit_reader.seek_bits(3)?; // reserved 3 bits + + let initial_presentation_delay_minus_one = if bit_reader.read_bit()? { + Some(bit_reader.read_bits(4)? as u8) + } else { + bit_reader.seek_bits(4)?; // reserved 4 bits + None + }; + + if !bit_reader.is_aligned() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Bit reader is not aligned", + )); + } + + let reader = bit_reader.into_inner(); + + Ok(AV1CodecConfigurationRecord { + marker, + version, + seq_profile, + seq_level_idx_0, + seq_tier_0, + high_bitdepth, + twelve_bit, + monochrome, + chroma_subsampling_x, + chroma_subsampling_y, + chroma_sample_position, + initial_presentation_delay_minus_one, + config_obu: reader.get_remaining(), + }) + } + + pub fn size(&self) -> u64 { + 1 // marker, version + + 1 // seq_profile, seq_level_idx_0 + + 1 // seq_tier_0, high_bitdepth, twelve_bit, monochrome, chroma_subsampling_x, chroma_subsampling_y, chroma_sample_position + + 1 // reserved, initial_presentation_delay_present, initial_presentation_delay_minus_one/reserved + + self.config_obu.len() as u64 + } + + pub fn mux(&self, writer: &mut T) -> io::Result<()> { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bit(self.marker)?; + bit_writer.write_bits(self.version as u64, 7)?; + + bit_writer.write_bits(self.seq_profile as u64, 3)?; + bit_writer.write_bits(self.seq_level_idx_0 as u64, 5)?; + + bit_writer.write_bit(self.seq_tier_0)?; + bit_writer.write_bit(self.high_bitdepth)?; + bit_writer.write_bit(self.twelve_bit)?; + bit_writer.write_bit(self.monochrome)?; + bit_writer.write_bit(self.chroma_subsampling_x)?; + bit_writer.write_bit(self.chroma_subsampling_y)?; + bit_writer.write_bits(self.chroma_sample_position as u64, 2)?; + + bit_writer.write_bits(0, 3)?; // reserved 3 bits + + if let Some(initial_presentation_delay_minus_one) = + self.initial_presentation_delay_minus_one + { + bit_writer.write_bit(true)?; + bit_writer.write_bits(initial_presentation_delay_minus_one as u64, 4)?; + } else { + bit_writer.write_bit(false)?; + bit_writer.write_bits(0, 4)?; // reserved 4 bits + } + + writer.write_all(&bit_writer.into_inner())?; + writer.write_all(&self.config_obu)?; + + Ok(()) + } +} diff --git a/video/codec/av1/src/lib.rs b/video/codec/av1/src/lib.rs new file mode 100644 index 00000000..91c085ae --- /dev/null +++ b/video/codec/av1/src/lib.rs @@ -0,0 +1,8 @@ +mod config; +mod obu; + +pub use config::AV1CodecConfigurationRecord; +pub use obu::{seq, ObuHeader, ObuType}; + +#[cfg(test)] +mod tests; diff --git a/video/codec/av1/src/obu/mod.rs b/video/codec/av1/src/obu/mod.rs new file mode 100644 index 00000000..85fc07e4 --- /dev/null +++ b/video/codec/av1/src/obu/mod.rs @@ -0,0 +1,163 @@ +use std::io::{self, Read}; + +use bytes::Bytes; +use bytesio::bit_reader::BitReader; + +pub mod seq; + +#[derive(Debug, Clone, PartialEq)] +/// OBU Header +/// AV1-Spec-2 - 5.3.2 +pub struct ObuHeader { + pub obu_type: ObuType, + pub extension_flag: bool, + pub has_size_field: bool, + pub extension_header: Option, +} + +#[derive(Debug, Clone, PartialEq)] +/// Obu Header Extension +/// AV1-Spec-2 - 5.3.3 +pub struct ObuHeaderExtension { + pub temporal_id: u8, + pub spatial_id: u8, +} + +impl ObuHeader { + pub fn parse(bit_reader: &mut BitReader) -> io::Result<(Self, Bytes)> { + let forbidden_bit = bit_reader.read_bit()?; + if forbidden_bit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "obu_forbidden_bit is not 0", + )); + } + + let obu_type = bit_reader.read_bits(4)?; + let extension_flag = bit_reader.read_bit()?; + let has_size_field = bit_reader.read_bit()?; + + let reserved_1bit = bit_reader.read_bit()?; + if reserved_1bit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "obu_reserved_1bit is not 0", + )); + } + + let extension_header = if extension_flag { + let temporal_id = bit_reader.read_bits(3)?; + let spatial_id = bit_reader.read_bits(2)?; + bit_reader.read_bits(3)?; // reserved_3bits + Some(ObuHeaderExtension { + temporal_id: temporal_id as u8, + spatial_id: spatial_id as u8, + }) + } else { + None + }; + + let size = if has_size_field { + // obu_size + read_leb128(bit_reader)? + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "obu_size is not present", + )); + }; + + let mut data = vec![0; size as usize]; + bit_reader.read_exact(&mut data)?; + + Ok(( + ObuHeader { + obu_type: ObuType::from(obu_type as u8), + extension_flag, + has_size_field, + extension_header, + }, + Bytes::from(data), + )) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// OBU Type +/// AV1-Spec-2 - 6.2.2 +pub enum ObuType { + SequenceHeader, + TemporalDelimiter, + FrameHeader, + TileGroup, + Metadata, + Frame, + RedundantFrameHeader, + TileList, + Padding, + Reserved(u8), +} + +impl From for ObuType { + fn from(value: u8) -> Self { + match value { + 1 => ObuType::SequenceHeader, + 2 => ObuType::TemporalDelimiter, + 3 => ObuType::FrameHeader, + 4 => ObuType::TileGroup, + 5 => ObuType::Metadata, + 6 => ObuType::Frame, + 7 => ObuType::RedundantFrameHeader, + 8 => ObuType::TileList, + 15 => ObuType::Padding, + _ => ObuType::Reserved(value), + } + } +} + +impl From for u8 { + fn from(value: ObuType) -> Self { + match value { + ObuType::SequenceHeader => 1, + ObuType::TemporalDelimiter => 2, + ObuType::FrameHeader => 3, + ObuType::TileGroup => 4, + ObuType::Metadata => 5, + ObuType::Frame => 6, + ObuType::RedundantFrameHeader => 7, + ObuType::TileList => 8, + ObuType::Padding => 15, + ObuType::Reserved(value) => value, + } + } +} + +/// Read a little-endian variable-length integer. +/// AV1-Spec-2 - 4.10.5 +fn read_leb128(reader: &mut BitReader) -> io::Result { + let mut result = 0; + for i in 0..8 { + let byte = reader.read_bits(8)?; + result |= (byte & 0x7f) << (i * 7); + if byte & 0x80 == 0 { + break; + } + } + Ok(result) +} + +/// Read a variable-length unsigned integer. +/// AV1-Spec-2 - 4.10.3 +fn read_uvlc(reader: &mut BitReader) -> io::Result { + let mut leading_zeros = 0; + while !reader.read_bit()? { + leading_zeros += 1; + } + + if leading_zeros >= 32 { + return Ok((1 << 32) - 1); + } + + let value = reader.read_bits(leading_zeros)?; + Ok(value + (1 << leading_zeros) - 1) +} diff --git a/video/codec/av1/src/obu/seq.rs b/video/codec/av1/src/obu/seq.rs new file mode 100644 index 00000000..863f91e3 --- /dev/null +++ b/video/codec/av1/src/obu/seq.rs @@ -0,0 +1,440 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::Bytes; +use bytesio::bit_reader::BitReader; + +use crate::obu::read_uvlc; + +use super::ObuHeader; + +#[derive(Debug, Clone, PartialEq)] +/// Sequence Header OBU +/// AV1-Spec-2 - 5.5 +pub struct SequenceHeaderObu { + pub header: ObuHeader, + pub seq_profile: u8, + pub still_picture: bool, + pub reduced_still_picture_header: bool, + pub timing_info: Option, + pub decoder_model_info: Option, + pub operating_points: Vec, + pub max_frame_width: u64, + pub max_frame_height: u64, + pub frame_ids: Option, + pub use_128x128_superblock: bool, + pub enable_filter_intra: bool, + pub enable_intra_edge_filter: bool, + pub enable_interintra_compound: bool, + pub enable_masked_compound: bool, + pub enable_warped_motion: bool, + pub enable_dual_filter: bool, + pub enable_order_hint: bool, + pub enable_jnt_comp: bool, + pub enable_ref_frame_mvs: bool, + pub seq_force_screen_content_tools: u8, + pub seq_force_integer_mv: u8, + pub order_hint_bits: u8, + pub enable_superres: bool, + pub enable_cdef: bool, + pub enable_restoration: bool, + pub color_config: ColorConfig, + pub film_grain_params_present: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FrameIds { + pub delta_frame_id_length: u8, + pub additional_frame_id_length: u8, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OperatingPoint { + pub idc: u16, + pub seq_level_idx: u8, + pub seq_tier: bool, + pub operating_parameters_info: Option, + pub initial_display_delay: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TimingInfo { + pub num_units_in_display_tick: u32, + pub time_scale: u32, + pub num_ticks_per_picture: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DecoderModelInfo { + pub buffer_delay_length: u8, + pub num_units_in_decoding_tick: u32, + pub buffer_removal_time_length: u8, + pub frame_presentation_time_length: u8, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OperatingParametersInfo { + pub decoder_buffer_delay: u64, + pub encoder_buffer_delay: u64, + pub low_delay_mode_flag: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColorConfig { + pub bit_depth: i32, + pub mono_chrome: bool, + pub num_planes: u8, + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, + pub full_color_range: bool, + pub subsampling_x: bool, + pub subsampling_y: bool, + pub chroma_sample_position: u8, + pub separate_uv_delta_q: bool, +} + +impl SequenceHeaderObu { + pub fn header(&self) -> &ObuHeader { + &self.header + } + + pub fn parse(header: ObuHeader, data: Bytes) -> io::Result { + let mut bit_reader = BitReader::from(data); + + let seq_profile = bit_reader.read_bits(3)? as u8; + let still_picture = bit_reader.read_bit()?; + let reduced_still_picture_header = bit_reader.read_bit()?; + + let mut timing_info = None; + let mut decoder_model_info = None; + let mut operating_points = Vec::new(); + + if reduced_still_picture_header { + operating_points.push(OperatingPoint { + idc: 0, + seq_level_idx: bit_reader.read_bits(5)? as u8, + seq_tier: false, + operating_parameters_info: None, + initial_display_delay: None, + }); + } else { + let timing_info_present_flag = bit_reader.read_bit()?; + if timing_info_present_flag { + let num_units_in_display_tick = bit_reader.read_u32::()?; + let time_scale = bit_reader.read_u32::()?; + let num_ticks_per_picture = if bit_reader.read_bit()? { + Some(read_uvlc(&mut bit_reader)? + 1) + } else { + None + }; + timing_info = Some(TimingInfo { + num_units_in_display_tick, + time_scale, + num_ticks_per_picture, + }); + + let decoder_model_info_present_flag = bit_reader.read_bit()?; + if decoder_model_info_present_flag { + let buffer_delay_length = bit_reader.read_bits(5)? as u8 + 1; + let num_units_in_decoding_tick = bit_reader.read_u32::()?; + let buffer_removal_time_length = bit_reader.read_bits(5)? as u8 + 1; + let frame_presentation_time_length = bit_reader.read_bits(5)? as u8 + 1; + decoder_model_info = Some(DecoderModelInfo { + buffer_delay_length, + num_units_in_decoding_tick, + buffer_removal_time_length, + frame_presentation_time_length, + }); + } + } + + let initial_display_delay_present_flag = bit_reader.read_bit()?; + let operating_points_cnt_minus_1 = bit_reader.read_bits(5)? as u8; + for _ in 0..=operating_points_cnt_minus_1 { + let idc = bit_reader.read_bits(12)? as u16; + let seq_level_idx = bit_reader.read_bits(5)? as u8; + let seq_tier = if seq_level_idx > 7 { + bit_reader.read_bit()? + } else { + false + }; + let decoder_model_present_for_this_op = if decoder_model_info.is_some() { + bit_reader.read_bit()? + } else { + false + }; + + let operating_parameters_info = if decoder_model_present_for_this_op { + let decoder_buffer_delay = bit_reader + .read_bits(decoder_model_info.as_ref().unwrap().buffer_delay_length)?; + let encoder_buffer_delay = bit_reader + .read_bits(decoder_model_info.as_ref().unwrap().buffer_delay_length)?; + let low_delay_mode_flag = bit_reader.read_bit()?; + Some(OperatingParametersInfo { + decoder_buffer_delay, + encoder_buffer_delay, + low_delay_mode_flag, + }) + } else { + None + }; + + let initial_display_delay = if initial_display_delay_present_flag { + if bit_reader.read_bit()? { + // initial_display_delay_present_for_this_op + Some(bit_reader.read_bits(4)? as u8 + 1) // initial_display_delay_minus_1 + } else { + None + } + } else { + None + }; + + operating_points.push(OperatingPoint { + idc, + seq_level_idx, + seq_tier, + operating_parameters_info, + initial_display_delay, + }); + } + } + + if operating_points.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "seq_obu parse error: no operating points", + )); + } + + let frame_width_bits = bit_reader.read_bits(4)? as u8 + 1; + let frame_height_bits = bit_reader.read_bits(4)? as u8 + 1; + + let max_frame_width = bit_reader.read_bits(frame_width_bits)? + 1; + let max_frame_height = bit_reader.read_bits(frame_height_bits)? + 1; + + let frame_id_numbers_present_flag = if reduced_still_picture_header { + false + } else { + bit_reader.read_bit()? + }; + let frame_ids = if frame_id_numbers_present_flag { + let delta_frame_id_length = bit_reader.read_bits(4)? as u8 + 2; + let additional_frame_id_length = bit_reader.read_bits(3)? as u8 + 1; + Some(FrameIds { + delta_frame_id_length, + additional_frame_id_length, + }) + } else { + None + }; + + let use_128x128_superblock = bit_reader.read_bit()?; + let enable_filter_intra = bit_reader.read_bit()?; + let enable_intra_edge_filter = bit_reader.read_bit()?; + + let enable_interintra_compound; + let enable_masked_compound; + let enable_warped_motion; + let enable_dual_filter; + let enable_order_hint; + let enable_jnt_comp; + let enable_ref_frame_mvs; + let order_hint_bits; + let seq_force_integer_mv; + + let seq_force_screen_content_tools; + + if !reduced_still_picture_header { + enable_interintra_compound = bit_reader.read_bit()?; + enable_masked_compound = bit_reader.read_bit()?; + enable_warped_motion = bit_reader.read_bit()?; + enable_dual_filter = bit_reader.read_bit()?; + enable_order_hint = bit_reader.read_bit()?; + if enable_order_hint { + enable_jnt_comp = bit_reader.read_bit()?; + enable_ref_frame_mvs = bit_reader.read_bit()?; + } else { + enable_jnt_comp = false; + enable_ref_frame_mvs = false; + } + if bit_reader.read_bit()? { + // seq_choose_screen_content_tools + seq_force_screen_content_tools = 2; // SELECT_SCREEN_CONTENT_TOOLS + } else { + seq_force_screen_content_tools = bit_reader.read_bits(1)? as u8; + } + + // If seq_force_screen_content_tools is 0, then seq_force_integer_mv must be 2. + // Or if the next bit is 0, then seq_force_integer_mv must be 2. + if seq_force_screen_content_tools == 0 || bit_reader.read_bit()? { + seq_force_integer_mv = 2; // SELECT_INTEGER_MV + } else { + seq_force_integer_mv = bit_reader.read_bits(1)? as u8; + } + + if enable_order_hint { + order_hint_bits = bit_reader.read_bits(3)? as u8 + 1; + } else { + order_hint_bits = 0; + } + } else { + enable_interintra_compound = false; + enable_masked_compound = false; + enable_warped_motion = false; + enable_dual_filter = false; + enable_order_hint = false; + enable_jnt_comp = false; + enable_ref_frame_mvs = false; + seq_force_screen_content_tools = 2; // SELECT_SCREEN_CONTENT_TOOLS + seq_force_integer_mv = 2; // SELECT_INTEGER_MV + order_hint_bits = 0; + } + + let enable_superres = bit_reader.read_bit()?; + let enable_cdef = bit_reader.read_bit()?; + let enable_restoration = bit_reader.read_bit()?; + + let high_bitdepth = bit_reader.read_bit()?; + let bit_depth = if seq_profile == 2 && high_bitdepth { + if bit_reader.read_bit()? { + 12 + } else { + 10 + } + } else if high_bitdepth { + 10 + } else { + 8 + }; + + let mono_chrome = if seq_profile == 1 { + false + } else { + bit_reader.read_bit()? + }; + + let color_primaries; + let transfer_characteristics; + let matrix_coefficients; + + let color_description_present_flag = bit_reader.read_bit()?; + if color_description_present_flag { + color_primaries = bit_reader.read_bits(8)? as u8; + transfer_characteristics = bit_reader.read_bits(8)? as u8; + matrix_coefficients = bit_reader.read_bits(8)? as u8; + } else { + color_primaries = 2; // CP_UNSPECIFIED + transfer_characteristics = 2; // TC_UNSPECIFIED + matrix_coefficients = 2; // MC_UNSPECIFIED + } + + let num_planes = if mono_chrome { 1 } else { 3 }; + + let color_config; + + if mono_chrome { + let color_range = bit_reader.read_bit()?; + let subsampling_x = true; + let subsampling_y = true; + color_config = ColorConfig { + bit_depth, + color_primaries, + transfer_characteristics, + matrix_coefficients, + full_color_range: color_range, + subsampling_x, + subsampling_y, + mono_chrome, + separate_uv_delta_q: false, + chroma_sample_position: 0, // CSP_UNKNOWN + num_planes, + } + } else { + let color_range; + let subsampling_x; + let subsampling_y; + + // color_primarties == CP_BT_709 && transfer_characteristics == TC_SRGB && matrix_coefficients == MC_IDENTITY + if color_primaries == 1 && transfer_characteristics == 13 && matrix_coefficients == 0 { + color_range = true; + subsampling_x = false; + subsampling_y = false; + } else { + color_range = bit_reader.read_bit()?; + if seq_profile == 0 { + subsampling_x = true; + subsampling_y = true; + } else if seq_profile == 1 { + subsampling_x = false; + subsampling_y = false; + } else if bit_depth == 12 { + subsampling_x = bit_reader.read_bit()?; + if subsampling_x { + subsampling_y = bit_reader.read_bit()?; + } else { + subsampling_y = false; + } + } else { + subsampling_x = true; + subsampling_y = false; + } + } + + let chroma_sample_position = if subsampling_x && subsampling_y { + bit_reader.read_bits(2)? as u8 + } else { + 0 // CSP_UNKNOWN + }; + + let separate_uv_delta_q = bit_reader.read_bit()?; + color_config = ColorConfig { + bit_depth, + mono_chrome, + color_primaries, + transfer_characteristics, + matrix_coefficients, + full_color_range: color_range, + subsampling_x, + subsampling_y, + chroma_sample_position, + separate_uv_delta_q, + num_planes, + }; + } + + let film_grain_params_present = bit_reader.read_bit()?; + + Ok(Self { + header, + seq_profile, + still_picture, + reduced_still_picture_header, + operating_points, + decoder_model_info, + max_frame_width, + max_frame_height, + frame_ids, + use_128x128_superblock, + enable_filter_intra, + enable_intra_edge_filter, + enable_interintra_compound, + enable_masked_compound, + enable_warped_motion, + enable_dual_filter, + enable_order_hint, + enable_jnt_comp, + enable_ref_frame_mvs, + seq_force_screen_content_tools, + seq_force_integer_mv, + order_hint_bits, + enable_superres, + enable_cdef, + enable_restoration, + timing_info, + color_config, + film_grain_params_present, + }) + } +} diff --git a/video/codec/av1/src/tests.rs b/video/codec/av1/src/tests.rs new file mode 100644 index 00000000..bd8b727c --- /dev/null +++ b/video/codec/av1/src/tests.rs @@ -0,0 +1,106 @@ +use std::io; + +use bytesio::bit_reader::BitReader; + +use crate::{ + config::AV1CodecConfigurationRecord, + seq::{ColorConfig, OperatingPoint, SequenceHeaderObu}, + ObuHeader, ObuType, +}; + +#[test] +fn test_config_demux() { + let data = b"\x81\r\x0c\0\n\x0f\0\0\0j\xef\xbf\xe1\xbc\x02\x19\x90\x10\x10\x10@".to_vec(); + + let config = AV1CodecConfigurationRecord::demux(&mut io::Cursor::new(data.into())).unwrap(); + + assert!(config.marker); + assert_eq!(config.version, 1); + assert_eq!(config.seq_profile, 0); + assert_eq!(config.seq_level_idx_0, 13); + assert!(!config.seq_tier_0); + assert!(!config.high_bitdepth); + assert!(!config.twelve_bit); + assert!(!config.monochrome); + assert!(config.chroma_subsampling_x); + assert!(config.chroma_subsampling_y); + assert_eq!(config.initial_presentation_delay_minus_one, None); + + let (header, data) = ObuHeader::parse(&mut BitReader::from(config.config_obu)).unwrap(); + + assert_eq!(header.obu_type, ObuType::SequenceHeader); + + let obu = SequenceHeaderObu::parse(header, data).unwrap(); + + assert_eq!( + obu, + SequenceHeaderObu { + header: ObuHeader { + obu_type: ObuType::SequenceHeader, + extension_flag: false, + has_size_field: true, + extension_header: None, + }, + seq_profile: 0, + still_picture: false, + reduced_still_picture_header: false, + timing_info: None, + decoder_model_info: None, + operating_points: vec![OperatingPoint { + idc: 0, + seq_level_idx: 13, + seq_tier: false, + operating_parameters_info: None, + initial_display_delay: None, + }], + max_frame_width: 3840, + max_frame_height: 2160, + frame_ids: None, + use_128x128_superblock: false, + enable_filter_intra: false, + enable_intra_edge_filter: false, + enable_interintra_compound: false, + enable_masked_compound: false, + enable_warped_motion: false, + enable_dual_filter: false, + enable_order_hint: true, + enable_jnt_comp: false, + enable_ref_frame_mvs: false, + seq_force_screen_content_tools: 0, + seq_force_integer_mv: 2, + order_hint_bits: 7, + enable_superres: false, + enable_cdef: true, + enable_restoration: true, + color_config: ColorConfig { + bit_depth: 8, + mono_chrome: false, + num_planes: 3, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_color_range: false, + subsampling_x: true, + subsampling_y: true, + chroma_sample_position: 0, + separate_uv_delta_q: false, + }, + film_grain_params_present: false, + } + ) +} + +#[test] +fn test_config_mux() { + let data = b"\x81\r\x0c\0\n\x0f\0\0\0j\xef\xbf\xe1\xbc\x02\x19\x90\x10\x10\x10@".to_vec(); + + let config = + AV1CodecConfigurationRecord::demux(&mut io::Cursor::new(data.clone().into())).unwrap(); + + assert_eq!(data.len() as u64, config.size()); + + let mut buf = Vec::new(); + config.mux(&mut buf).unwrap(); + + assert_eq!(buf, data); +} diff --git a/video/codec/h264/Cargo.toml b/video/codec/h264/Cargo.toml new file mode 100644 index 00000000..17312ef6 --- /dev/null +++ b/video/codec/h264/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "h264" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +byteorder = "1" +bytesio = { path = "../../bytesio", default-features = false } +exp_golomb = { path = "../../utils/exp_golomb" } diff --git a/video/codec/h264/src/config.rs b/video/codec/h264/src/config.rs new file mode 100644 index 00000000..0e336896 --- /dev/null +++ b/video/codec/h264/src/config.rs @@ -0,0 +1,172 @@ +use std::io::{self, Write}; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{Buf, Bytes}; +use bytesio::{bit_writer::BitWriter, bytes_reader::BytesCursor}; + +#[derive(Debug, Clone, PartialEq)] +/// AVC (H.264) Decoder Configuration Record +/// ISO/IEC 14496-15:2022(E) - 5.3.2.1.2 +pub struct AVCDecoderConfigurationRecord { + pub configuration_version: u8, + pub profile_indication: u8, + pub profile_compatibility: u8, + pub level_indication: u8, + pub length_size_minus_one: u8, + pub sps: Vec, + pub pps: Vec, + pub extended_config: Option, +} + +#[derive(Debug, Clone, PartialEq)] +/// AVC (H.264) Extended Configuration +/// ISO/IEC 14496-15:2022(E) - 5.3.2.1.2 +pub struct AvccExtendedConfig { + pub chroma_format: u8, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub sequence_parameter_set_ext: Vec, +} + +impl AVCDecoderConfigurationRecord { + pub fn demux(reader: &mut io::Cursor) -> io::Result { + let configuration_version = reader.read_u8()?; + let profile_indication = reader.read_u8()?; + let profile_compatibility = reader.read_u8()?; + let level_indication = reader.read_u8()?; + let length_size_minus_one = reader.read_u8()? & 0b00000011; + let num_of_sequence_parameter_sets = reader.read_u8()? & 0b00011111; + + let mut sps = Vec::with_capacity(num_of_sequence_parameter_sets as usize); + for _ in 0..num_of_sequence_parameter_sets { + let sps_length = reader.read_u16::()?; + let sps_data = reader.read_slice(sps_length as usize)?; + sps.push(sps_data); + } + + let num_of_picture_parameter_sets = reader.read_u8()?; + let mut pps = Vec::with_capacity(num_of_picture_parameter_sets as usize); + for _ in 0..num_of_picture_parameter_sets { + let pps_length = reader.read_u16::()?; + let pps_data = reader.read_slice(pps_length as usize)?; + pps.push(pps_data); + } + + // It turns out that sometimes the extended config is not present, even though the avc_profile_indication + // is not 66, 77 or 88. We need to be lenient here on decoding. + let extended_config = match profile_indication { + 66 | 77 | 88 => None, + _ => { + if reader.has_remaining() { + let chroma_format = reader.read_u8()? & 0b00000011; // 2 bits (6 bits reserved) + let bit_depth_luma_minus8 = reader.read_u8()? & 0b00000111; // 3 bits (5 bits reserved) + let bit_depth_chroma_minus8 = reader.read_u8()? & 0b00000111; // 3 bits (5 bits reserved) + let number_of_sequence_parameter_set_ext = reader.read_u8()?; // 8 bits + + let mut sequence_parameter_set_ext = + Vec::with_capacity(number_of_sequence_parameter_set_ext as usize); + for _ in 0..number_of_sequence_parameter_set_ext { + let sps_ext_length = reader.read_u16::()?; + let sps_ext_data = reader.read_slice(sps_ext_length as usize)?; + sequence_parameter_set_ext.push(sps_ext_data); + } + + Some(AvccExtendedConfig { + chroma_format, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + sequence_parameter_set_ext, + }) + } else { + // No extended config present even though avc_profile_indication is not 66, 77 or 88 + None + } + } + }; + + Ok(Self { + configuration_version, + profile_indication, + profile_compatibility, + level_indication, + length_size_minus_one, + sps, + pps, + extended_config, + }) + } + + pub fn size(&self) -> u64 { + 1 // configuration_version + + 1 // avc_profile_indication + + 1 // profile_compatibility + + 1 // avc_level_indication + + 1 // length_size_minus_one + + 1 // num_of_sequence_parameter_sets (5 bits reserved, 3 bits) + + self.sps.iter().map(|sps| { + 2 // sps_length + + sps.len() as u64 + }).sum::() // sps + + 1 // num_of_picture_parameter_sets + + self.pps.iter().map(|pps| { + 2 // pps_length + + pps.len() as u64 + }).sum::() // pps + + match &self.extended_config { + Some(config) => { + 1 // chroma_format (6 bits reserved, 2 bits) + + 1 // bit_depth_luma_minus8 (5 bits reserved, 3 bits) + + 1 // bit_depth_chroma_minus8 (5 bits reserved, 3 bits) + + 1 // number_of_sequence_parameter_set_ext + + config.sequence_parameter_set_ext.iter().map(|sps_ext| { + 2 // sps_ext_length + + sps_ext.len() as u64 + }).sum::() // sps_ext + } + None => 0, + } + } + + pub fn mux(&self, writer: &mut T) -> io::Result<()> { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_u8(self.configuration_version)?; + bit_writer.write_u8(self.profile_indication)?; + bit_writer.write_u8(self.profile_compatibility)?; + bit_writer.write_u8(self.level_indication)?; + bit_writer.write_bits(0b111111, 6)?; + bit_writer.write_bits(self.length_size_minus_one as u64, 2)?; + bit_writer.write_bits(0b111, 3)?; + + bit_writer.write_bits(self.sps.len() as u64, 5)?; + for sps in &self.sps { + bit_writer.write_u16::(sps.len() as u16)?; + bit_writer.write_all(sps)?; + } + + bit_writer.write_bits(self.pps.len() as u64, 8)?; + for pps in &self.pps { + bit_writer.write_u16::(pps.len() as u16)?; + bit_writer.write_all(pps)?; + } + + if let Some(config) = &self.extended_config { + bit_writer.write_bits(0b111111, 6)?; + bit_writer.write_bits(config.chroma_format as u64, 2)?; + bit_writer.write_bits(0b11111, 5)?; + bit_writer.write_bits(config.bit_depth_luma_minus8 as u64, 3)?; + bit_writer.write_bits(0b11111, 5)?; + bit_writer.write_bits(config.bit_depth_chroma_minus8 as u64, 3)?; + + bit_writer.write_bits(config.sequence_parameter_set_ext.len() as u64, 8)?; + for sps_ext in &config.sequence_parameter_set_ext { + bit_writer.write_u16::(sps_ext.len() as u16)?; + bit_writer.write_all(sps_ext)?; + } + } + + writer.write_all(&bit_writer.into_inner())?; + + Ok(()) + } +} diff --git a/video/codec/h264/src/lib.rs b/video/codec/h264/src/lib.rs new file mode 100644 index 00000000..8cf2732a --- /dev/null +++ b/video/codec/h264/src/lib.rs @@ -0,0 +1,10 @@ +mod config; +mod sps; + +pub use self::{ + config::{AVCDecoderConfigurationRecord, AvccExtendedConfig}, + sps::{ColorConfig, Sps, SpsExtended}, +}; + +#[cfg(test)] +mod tests; diff --git a/video/codec/h264/src/sps.rs b/video/codec/h264/src/sps.rs new file mode 100644 index 00000000..d7fdcd48 --- /dev/null +++ b/video/codec/h264/src/sps.rs @@ -0,0 +1,256 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::Bytes; +use bytesio::bit_reader::BitReader; + +use exp_golomb::{read_exp_golomb, read_signed_exp_golomb}; + +#[derive(Debug, Clone, PartialEq)] +/// Sequence parameter set +/// ISO/IEC-14496-10-2022 - 7.3.2 +pub struct Sps { + pub profile_idc: u8, + pub level_idc: u8, + pub ext: Option, + pub width: u64, + pub height: u64, + pub frame_rate: f64, + pub color_config: Option, +} + +#[derive(Debug, Clone, PartialEq)] +/// Color config for SPS +pub struct ColorConfig { + pub full_range: bool, + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, +} + +impl Sps { + pub fn parse(data: Bytes) -> io::Result { + let mut vec = Vec::with_capacity(data.len()); + + // We need to remove the emulation prevention byte + // This is BARELY documented in the spec, but it's there. + // ISO/IEC-14496-10-2022 - 3.1.48 + let mut i = 0; + while i < data.len() - 3 { + if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x03 { + vec.push(0x00); + vec.push(0x00); + i += 3; + } else { + vec.push(data[i]); + i += 1; + } + } + + let mut bit_reader = BitReader::from(vec); + + let forbidden_zero_bit = bit_reader.read_bit()?; + if forbidden_zero_bit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Forbidden zero bit is set", + )); + } + + bit_reader.seek_bits(2)?; // nal_ref_idc + + let nal_unit_type = bit_reader.read_bits(5)?; + if nal_unit_type != 7 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "NAL unit type is not SPS", + )); + } + + let profile_idc = bit_reader.read_u8()?; + bit_reader.seek_bits( + 1 // constraint_set0_flag + + 1 // constraint_set1_flag + + 1 // constraint_set2_flag + + 1 // constraint_set3_flag + + 4, // reserved_zero_4bits + )?; + + let level_idc = bit_reader.read_u8()?; + read_exp_golomb(&mut bit_reader)?; // seq_parameter_set_id + + let sps_ext = match profile_idc { + 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 => { + Some(SpsExtended::parse(&mut bit_reader)?) + } + _ => None, + }; + + read_exp_golomb(&mut bit_reader)?; // log2_max_frame_num_minus4 + let pic_order_cnt_type = read_exp_golomb(&mut bit_reader)?; + if pic_order_cnt_type == 0 { + read_exp_golomb(&mut bit_reader)?; // log2_max_pic_order_cnt_lsb_minus4 + } else if pic_order_cnt_type == 1 { + bit_reader.seek_bits(1)?; // delta_pic_order_always_zero_flag + read_signed_exp_golomb(&mut bit_reader)?; // offset_for_non_ref_pic + read_signed_exp_golomb(&mut bit_reader)?; // offset_for_top_to_bottom_field + let num_ref_frames_in_pic_order_cnt_cycle = read_exp_golomb(&mut bit_reader)?; + for _ in 0..num_ref_frames_in_pic_order_cnt_cycle { + read_signed_exp_golomb(&mut bit_reader)?; // offset_for_ref_frame + } + } + + read_exp_golomb(&mut bit_reader)?; // max_num_ref_frames + bit_reader.read_bit()?; // gaps_in_frame_num_value_allowed_flag + let pic_width_in_mbs_minus1 = read_exp_golomb(&mut bit_reader)?; // pic_width_in_mbs_minus1 + let pic_height_in_map_units_minus1 = read_exp_golomb(&mut bit_reader)?; // pic_height_in_map_units_minus1 + let frame_mbs_only_flag = bit_reader.read_bit()?; + if !frame_mbs_only_flag { + bit_reader.seek_bits(1)?; // mb_adaptive_frame_field_flag + } + + bit_reader.seek_bits(1)?; // direct_8x8_inference_flag + + let mut frame_crop_left_offset = 0; + let mut frame_crop_right_offset = 0; + let mut frame_crop_top_offset = 0; + let mut frame_crop_bottom_offset = 0; + + if bit_reader.read_bit()? { + // frame_cropping_flag + frame_crop_left_offset = read_exp_golomb(&mut bit_reader)?; // frame_crop_left_offset + frame_crop_right_offset = read_exp_golomb(&mut bit_reader)?; // frame_crop_right_offset + frame_crop_top_offset = read_exp_golomb(&mut bit_reader)?; // frame_crop_top_offset + frame_crop_bottom_offset = read_exp_golomb(&mut bit_reader)?; // frame_crop_bottom_offset + } + + let width = ((pic_width_in_mbs_minus1 + 1) * 16) + - frame_crop_bottom_offset * 2 + - frame_crop_top_offset * 2; + let height = ((2 - frame_mbs_only_flag as u64) * (pic_height_in_map_units_minus1 + 1) * 16) + - (frame_crop_right_offset * 2) + - (frame_crop_left_offset * 2); + let mut frame_rate = 0.0; + + let vui_parameters_present_flag = bit_reader.read_bit()?; + + let mut color_config = None; + + if vui_parameters_present_flag { + // We do want to read the VUI parameters to get the frame rate. + + // aspect_ratio_info_present_flag + if bit_reader.read_bit()? { + let aspect_ratio_idc = bit_reader.read_u8()?; + if aspect_ratio_idc == 255 { + bit_reader.seek_bits(16)?; // sar_width + bit_reader.seek_bits(16)?; // sar_height + } + } + + // overscan_info_present_flag + if bit_reader.read_bit()? { + bit_reader.seek_bits(1)?; // overscan_appropriate_flag + } + + // video_signal_type_present_flag + if bit_reader.read_bit()? { + bit_reader.seek_bits(3)?; // video_format + let full_range = bit_reader.read_bit()?; // video_full_range_flag + + let color_primaries; + let transfer_characteristics; + let matrix_coefficients; + + if bit_reader.read_bit()? { + // colour_description_present_flag + color_primaries = bit_reader.read_u8()?; // colour_primaries + transfer_characteristics = bit_reader.read_u8()?; // transfer_characteristics + matrix_coefficients = bit_reader.read_u8()?; // matrix_coefficients + } else { + color_primaries = 2; // UNSPECIFIED + transfer_characteristics = 2; // UNSPECIFIED + matrix_coefficients = 2; // UNSPECIFIED + } + + color_config = Some(ColorConfig { + full_range, + color_primaries, + transfer_characteristics, + matrix_coefficients, + }); + } + + // chroma_loc_info_present_flag + if bit_reader.read_bit()? { + read_exp_golomb(&mut bit_reader)?; // chroma_sample_loc_type_top_field + read_exp_golomb(&mut bit_reader)?; // chroma_sample_loc_type_bottom_field + } + + // timing_info_present_flag + if bit_reader.read_bit()? { + let num_units_in_tick = bit_reader.read_u32::()?; + let time_scale = bit_reader.read_u32::()?; + frame_rate = time_scale as f64 / (2.0 * num_units_in_tick as f64); + } + } + + Ok(Sps { + profile_idc, + level_idc, + ext: sps_ext, + width, + height, + frame_rate, + color_config, + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Sequence parameter set extension. +/// ISO/IEC-14496-10-2022 - 7.3.2 +pub struct SpsExtended { + pub chroma_format_idc: u64, // ue(v) + pub bit_depth_luma_minus8: u64, // ue(v) + pub bit_depth_chroma_minus8: u64, // ue(v) +} + +impl SpsExtended { + pub fn parse(reader: &mut BitReader) -> io::Result { + let chroma_format_idc = read_exp_golomb(reader)?; + if chroma_format_idc == 3 { + reader.seek_bits(1)?; + } + + let bit_depth_luma_minus8 = read_exp_golomb(reader)?; + let bit_depth_chroma_minus8 = read_exp_golomb(reader)?; + reader.seek_bits(1)?; // qpprime_y_zero_transform_bypass_flag + + if reader.read_bit()? { + // seq_scaling_matrix_present_flag + // We need to read the scaling matrices here, but we don't need them + // for decoding, so we just skip them. + let count = if chroma_format_idc != 3 { 8 } else { 12 }; + for i in 0..count { + if reader.read_bit()? { + let size = if i < 6 { 16 } else { 64 }; + let mut next_scale = 8; + for _ in 0..size { + let delta_scale = read_signed_exp_golomb(reader)?; + next_scale = (next_scale + delta_scale + 256) % 256; + if next_scale == 0 { + break; + } + } + } + } + } + + Ok(SpsExtended { + chroma_format_idc, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + }) + } +} diff --git a/video/codec/h264/src/tests.rs b/video/codec/h264/src/tests.rs new file mode 100644 index 00000000..ad66996b --- /dev/null +++ b/video/codec/h264/src/tests.rs @@ -0,0 +1,116 @@ +use std::io; + +use bytes::Bytes; + +use crate::{ + config::{AVCDecoderConfigurationRecord, AvccExtendedConfig}, + sps::{ColorConfig, Sps, SpsExtended}, +}; + +#[test] +fn test_parse_sps() { + let sps = Bytes::from(vec![ + 103, 100, 0, 51, 172, 202, 80, 15, 0, 16, 251, 1, 16, 0, 0, 3, 0, 16, 0, 0, 7, 136, 241, + 131, 25, 96, + ]); + + let sps = Sps::parse(sps).unwrap(); + + assert_eq!(sps.profile_idc, 100); + assert_eq!(sps.level_idc, 51); + assert_eq!( + sps.ext, + Some(SpsExtended { + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + }) + ); + assert_eq!(sps.width, 3840); + assert_eq!(sps.height, 2160); + assert_eq!(sps.frame_rate, 60.0); + assert_eq!(sps.color_config, None); +} + +#[test] +fn test_parse_sps2() { + let sps = Bytes::from(vec![ + 0x67, 0x42, 0xc0, 0x1f, 0x8c, 0x8d, 0x40, 0x50, 0x1e, 0x90, 0x0f, 0x08, 0x84, 0x6a, + ]); + + let sps = Sps::parse(sps).unwrap(); + + assert_eq!(sps.profile_idc, 66); + assert_eq!(sps.level_idc, 31); + assert_eq!(sps.ext, None); + assert_eq!(sps.width, 640); + assert_eq!(sps.height, 480); + assert_eq!(sps.frame_rate, 0.0); + assert_eq!(sps.color_config, None); +} + +#[test] +fn test_config_demux() { + let data = Bytes::from(b"\x01d\0\x1f\xff\xe1\0\x1dgd\0\x1f\xac\xd9A\xe0m\xf9\xe6\xa0 (\0\0\x03\0\x08\0\0\x03\x01\xe0x\xc1\x8c\xb0\x01\0\x06h\xeb\xe3\xcb\"\xc0\xfd\xf8\xf8\0".to_vec()); + + let config = AVCDecoderConfigurationRecord::demux(&mut io::Cursor::new(data)).unwrap(); + + assert_eq!(config.configuration_version, 1); + assert_eq!(config.profile_indication, 100); + assert_eq!(config.profile_compatibility, 0); + assert_eq!(config.level_indication, 31); + assert_eq!(config.length_size_minus_one, 3); + assert_eq!( + config.extended_config, + Some(AvccExtendedConfig { + bit_depth_chroma_minus8: 0, + bit_depth_luma_minus8: 0, + chroma_format: 1, + sequence_parameter_set_ext: vec![], + }) + ); + + assert_eq!(config.sps.len(), 1); + assert_eq!(config.pps.len(), 1); + + let sps = &config.sps[0]; + let sps = Sps::parse(sps.clone()).unwrap(); + + assert_eq!(sps.profile_idc, 100); + assert_eq!(sps.level_idc, 31); + assert_eq!( + sps.ext, + Some(SpsExtended { + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + }) + ); + + assert_eq!(sps.width, 468); + assert_eq!(sps.height, 864); + assert_eq!(sps.frame_rate, 30.0); + assert_eq!( + sps.color_config, + Some(ColorConfig { + full_range: false, + matrix_coefficients: 1, + color_primaries: 1, + transfer_characteristics: 1, + }) + ) +} + +#[test] +fn test_config_mux() { + let data = Bytes::from(b"\x01d\0\x1f\xff\xe1\0\x1dgd\0\x1f\xac\xd9A\xe0m\xf9\xe6\xa0 (\0\0\x03\0\x08\0\0\x03\x01\xe0x\xc1\x8c\xb0\x01\0\x06h\xeb\xe3\xcb\"\xc0\xfd\xf8\xf8\0".to_vec()); + + let config = AVCDecoderConfigurationRecord::demux(&mut io::Cursor::new(data.clone())).unwrap(); + + assert_eq!(config.size(), data.len() as u64); + + let mut buf = Vec::new(); + config.mux(&mut buf).unwrap(); + + assert_eq!(buf, data.to_vec()); +} diff --git a/video/codec/h265/Cargo.toml b/video/codec/h265/Cargo.toml new file mode 100644 index 00000000..ec5a7164 --- /dev/null +++ b/video/codec/h265/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "h265" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +byteorder = "1" +bytesio = { path = "../../bytesio", default-features = false } +exp_golomb = { path = "../../utils/exp_golomb" } diff --git a/video/codec/h265/src/config.rs b/video/codec/h265/src/config.rs new file mode 100644 index 00000000..fb906749 --- /dev/null +++ b/video/codec/h265/src/config.rs @@ -0,0 +1,230 @@ +use std::io::{self, Read, Write}; + +use byteorder::{BigEndian, LittleEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use bytesio::{bit_reader::BitReader, bit_writer::BitWriter}; + +#[derive(Debug, Clone, PartialEq)] +/// HEVC Decoder Configuration Record +/// ISO/IEC 14496-15:2022(E) - 8.3.2.1 +pub struct HEVCDecoderConfigurationRecord { + pub configuration_version: u8, + pub general_profile_space: u8, + pub general_tier_flag: bool, + pub general_profile_idc: u8, + pub general_profile_compatibility_flags: u32, + pub general_constraint_indicator_flags: u64, + pub general_level_idc: u8, + pub min_spatial_segmentation_idc: u16, + pub parallelism_type: u8, + pub chroma_format_idc: u8, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub avg_frame_rate: u16, + pub constant_frame_rate: u8, + pub num_temporal_layers: u8, + pub temporal_id_nested: bool, + pub length_size_minus_one: u8, + pub arrays: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Nalu Array Structure +/// ISO/IEC 14496-15:2022(E) - 8.3.2.1 +pub struct NaluArray { + pub array_completeness: bool, + pub nal_unit_type: NaluType, + pub nalus: Vec, +} + +#[derive(Debug, Clone, PartialEq, Copy)] +/// Nalu Type +/// ISO/IEC 23008-2:2020(E) - 7.4.2.2 (Table 7-1) +pub enum NaluType { + Vps, + Pps, + Sps, + Unknown(u8), +} + +impl From for NaluType { + fn from(value: u8) -> Self { + match value { + 32 => NaluType::Vps, + 33 => NaluType::Sps, + 34 => NaluType::Pps, + _ => NaluType::Unknown(value), + } + } +} + +impl From for u8 { + fn from(value: NaluType) -> Self { + match value { + NaluType::Vps => 32, + NaluType::Sps => 33, + NaluType::Pps => 34, + NaluType::Unknown(value) => value, + } + } +} + +impl HEVCDecoderConfigurationRecord { + pub fn demux(data: &mut io::Cursor) -> io::Result { + let mut bit_reader = BitReader::new(data); + + let configuration_version = bit_reader.read_u8()?; + let general_profile_space = bit_reader.read_bits(2)? as u8; + let general_tier_flag = bit_reader.read_bit()?; + let general_profile_idc = bit_reader.read_bits(5)? as u8; + let general_profile_compatibility_flags = bit_reader.read_u32::()?; + let general_constraint_indicator_flags = bit_reader.read_u48::()?; + let general_level_idc = bit_reader.read_u8()?; + + bit_reader.seek_bits(4)?; // reserved_4bits + let min_spatial_segmentation_idc = bit_reader.read_bits(12)? as u16; + + bit_reader.seek_bits(6)?; // reserved_6bits + let parallelism_type = bit_reader.read_bits(2)? as u8; + + bit_reader.seek_bits(6)?; // reserved_6bits + let chroma_format_idc = bit_reader.read_bits(2)? as u8; + + bit_reader.seek_bits(5)?; // reserved_5bits + let bit_depth_luma_minus8 = bit_reader.read_bits(3)? as u8; + + bit_reader.seek_bits(5)?; // reserved_5bits + let bit_depth_chroma_minus8 = bit_reader.read_bits(3)? as u8; + + let avg_frame_rate = bit_reader.read_u16::()?; + let constant_frame_rate = bit_reader.read_bits(2)? as u8; + let num_temporal_layers = bit_reader.read_bits(3)? as u8; + let temporal_id_nested = bit_reader.read_bit()?; + let length_size_minus_one = bit_reader.read_bits(2)? as u8; + + let num_of_arrays = bit_reader.read_u8()?; + + let mut arrays = Vec::with_capacity(num_of_arrays as usize); + + for _ in 0..num_of_arrays { + let array_completeness = bit_reader.read_bit()?; + bit_reader.seek_bits(1)?; // reserved + + let nal_unit_type = bit_reader.read_bits(6)? as u8; + + let num_nalus = bit_reader.read_u16::()?; + + let mut nalus = Vec::with_capacity(num_nalus as usize); + + for _ in 0..num_nalus { + let nal_unit_length = bit_reader.read_u16::()?; + let mut data = vec![0; nal_unit_length as usize]; + bit_reader.read_exact(&mut data)?; + nalus.push(data.into()); + } + + arrays.push(NaluArray { + array_completeness, + nal_unit_type: nal_unit_type.into(), + nalus, + }); + } + + Ok(HEVCDecoderConfigurationRecord { + configuration_version, + general_profile_space, + general_tier_flag, + general_profile_idc, + general_profile_compatibility_flags, + general_constraint_indicator_flags, + general_level_idc, + min_spatial_segmentation_idc, + parallelism_type, + chroma_format_idc, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + avg_frame_rate, + constant_frame_rate, + num_temporal_layers, + temporal_id_nested, + length_size_minus_one, + arrays, + }) + } + + pub fn size(&self) -> u64 { + 1 // configuration_version + + 1 // general_profile_space, general_tier_flag, general_profile_idc + + 4 // general_profile_compatibility_flags + + 6 // general_constraint_indicator_flags + + 1 // general_level_idc + + 2 // reserved_4bits, min_spatial_segmentation_idc + + 1 // reserved_6bits, parallelism_type + + 1 // reserved_6bits, chroma_format_idc + + 1 // reserved_5bits, bit_depth_luma_minus8 + + 1 // reserved_5bits, bit_depth_chroma_minus8 + + 2 // avg_frame_rate + + 1 // constant_frame_rate, num_temporal_layers, temporal_id_nested, length_size_minus_one + + 1 // num_of_arrays + + self.arrays.iter().map(|array| { + 1 // array_completeness, reserved, nal_unit_type + + 2 // num_nalus + + array.nalus.iter().map(|nalu| { + 2 // nal_unit_length + + nalu.len() as u64 // nal_unit + }).sum::() + }).sum::() + } + + pub fn mux(&self, writer: &mut T) -> io::Result<()> { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_u8(self.configuration_version)?; + bit_writer.write_bits(self.general_profile_space as u64, 2)?; + bit_writer.write_bit(self.general_tier_flag)?; + bit_writer.write_bits(self.general_profile_idc as u64, 5)?; + bit_writer.write_u32::(self.general_profile_compatibility_flags)?; + bit_writer.write_u48::(self.general_constraint_indicator_flags)?; + bit_writer.write_u8(self.general_level_idc)?; + + bit_writer.write_bits(0b1111, 4)?; // reserved_4bits + bit_writer.write_bits(self.min_spatial_segmentation_idc as u64, 12)?; + + bit_writer.write_bits(0b111111, 6)?; // reserved_6bits + bit_writer.write_bits(self.parallelism_type as u64, 2)?; + + bit_writer.write_bits(0b111111, 6)?; // reserved_6bits + bit_writer.write_bits(self.chroma_format_idc as u64, 2)?; + + bit_writer.write_bits(0b11111, 5)?; // reserved_5bits + bit_writer.write_bits(self.bit_depth_luma_minus8 as u64, 3)?; + + bit_writer.write_bits(0b11111, 5)?; // reserved_5bits + bit_writer.write_bits(self.bit_depth_chroma_minus8 as u64, 3)?; + + bit_writer.write_u16::(self.avg_frame_rate)?; + bit_writer.write_bits(self.constant_frame_rate as u64, 2)?; + + bit_writer.write_bits(self.num_temporal_layers as u64, 3)?; + bit_writer.write_bit(self.temporal_id_nested)?; + bit_writer.write_bits(self.length_size_minus_one as u64, 2)?; + + bit_writer.write_u8(self.arrays.len() as u8)?; + for array in &self.arrays { + bit_writer.write_bit(array.array_completeness)?; + bit_writer.write_bits(0b0, 1)?; // reserved + bit_writer.write_bits(u8::from(array.nal_unit_type) as u64, 6)?; + + bit_writer.write_u16::(array.nalus.len() as u16)?; + + for nalu in &array.nalus { + bit_writer.write_u16::(nalu.len() as u16)?; + bit_writer.write_all(nalu)?; + } + } + + writer.write_all(&bit_writer.into_inner())?; + + Ok(()) + } +} diff --git a/video/codec/h265/src/lib.rs b/video/codec/h265/src/lib.rs new file mode 100644 index 00000000..b6e3b2b4 --- /dev/null +++ b/video/codec/h265/src/lib.rs @@ -0,0 +1,10 @@ +mod config; +mod sps; + +pub use self::{ + config::{HEVCDecoderConfigurationRecord, NaluArray, NaluType}, + sps::{ColorConfig, Sps}, +}; + +#[cfg(test)] +mod tests; diff --git a/video/codec/h265/src/sps.rs b/video/codec/h265/src/sps.rs new file mode 100644 index 00000000..0e5d0b7c --- /dev/null +++ b/video/codec/h265/src/sps.rs @@ -0,0 +1,336 @@ +use std::io; + +use byteorder::ReadBytesExt; +use bytes::Bytes; +use bytesio::bit_reader::BitReader; + +use exp_golomb::{read_exp_golomb, read_signed_exp_golomb}; + +#[derive(Debug, Clone, PartialEq)] +/// Sequence parameter set +/// ISO/IEC-14496-10-2022 - 7.3.2 +pub struct Sps { + pub width: u64, + pub height: u64, + pub frame_rate: f64, + pub color_config: Option, +} + +#[derive(Debug, Clone, PartialEq)] +/// Color Config for SPS +pub struct ColorConfig { + pub full_range: bool, + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, +} + +impl Sps { + pub fn parse(data: Bytes) -> io::Result { + let mut vec = Vec::with_capacity(data.len()); + + // ISO/IEC-23008-2-2022 - 7.3.1.1 + let mut i = 0; + while i < data.len() - 3 { + if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x03 { + vec.push(0x00); + vec.push(0x00); + i += 3; + } else { + vec.push(data[i]); + i += 1; + } + } + + let mut bit_reader = BitReader::from(vec); + + let forbidden_zero_bit = bit_reader.read_bit()?; + if forbidden_zero_bit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "forbidden_zero_bit is not zero", + )); + } + + let nalu_type = bit_reader.read_bits(6)?; + if nalu_type != 33 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "nalu_type is not 33", // SPS + )); + } + + bit_reader.seek_bits( + 6 // nuh_layer_id + + 3 // nuh_temporal_id_plus1 + + 4, // sps_video_parameter_set_id + )?; + + let sps_max_sub_layers_minus1 = bit_reader.read_bits(3)?; + bit_reader.seek_bits(1)?; // sps_temporal_id_nesting_flag + { + bit_reader.seek_bits( + 2 // general_profile_space + + 1 // general_tier_flag + + 5 // general_profile_idc + + 32 // general_profile_compatibility_flag + + 1 // general_progressive_source_flag + + 1 // general_interlaced_source_flag + + 1 // general_non_packed_constraint_flag + + 1 // general_frame_only_constraint_flag + + 43 // general_reserved_zero_43bits + + 1 // general_reserved_zero_bit + + 8, // general_level_idc + )?; + + let mut sub_layer_level_present_flags = vec![false; sps_max_sub_layers_minus1 as usize]; + for v in sub_layer_level_present_flags.iter_mut() { + bit_reader.seek_bits(1)?; // sub_layer_profile_present_flag + *v = bit_reader.read_bit()?; // sub_layer_level_present_flag + } + + if sps_max_sub_layers_minus1 > 0 && sps_max_sub_layers_minus1 < 8 { + bit_reader.seek_bits(2 * (8 - sps_max_sub_layers_minus1 as i64))?; + // reserved_zero_2bits + } + + for v in sub_layer_level_present_flags.drain(..) { + bit_reader.seek_bits( + 2 // sub_layer_profile_space + + 1 // sub_layer_tier_flag + + 5 // sub_layer_profile_idc + + 32 // sub_layer_profile_compatibility_flag[32] + + 1 // sub_layer_progressive_source_flag + + 1 // sub_layer_interlaced_source_flag + + 1 // sub_layer_non_packed_constraint_flag + + 1 // sub_layer_frame_only_constraint_flag + + 43 // sub_layer_reserved_zero_44bits + + 1, // sub_layer_reserved_zero_bit + )?; + if v { + bit_reader.seek_bits(8)?; // sub_layer_level_idc + } + } + } + + read_exp_golomb(&mut bit_reader)?; // sps_seq_parameter_set_id + let chroma_format_idc = read_exp_golomb(&mut bit_reader)?; + if chroma_format_idc == 3 { + bit_reader.read_bit()?; + } + let pic_width_in_luma_samples = read_exp_golomb(&mut bit_reader)?; + let pic_height_in_luma_samples = read_exp_golomb(&mut bit_reader)?; + let conformance_window_flag = bit_reader.read_bit()?; + + let conf_win_left_offset; + let conf_win_right_offset; + let conf_win_top_offset; + let conf_win_bottom_offset; + + if conformance_window_flag { + conf_win_left_offset = read_exp_golomb(&mut bit_reader)?; + conf_win_right_offset = read_exp_golomb(&mut bit_reader)?; + conf_win_top_offset = read_exp_golomb(&mut bit_reader)?; + conf_win_bottom_offset = read_exp_golomb(&mut bit_reader)?; + } else { + conf_win_left_offset = 0; + conf_win_right_offset = 0; + conf_win_top_offset = 0; + conf_win_bottom_offset = 0; + } + + let width = pic_width_in_luma_samples - conf_win_left_offset - conf_win_right_offset; + let height = pic_height_in_luma_samples - conf_win_top_offset - conf_win_bottom_offset; + + read_exp_golomb(&mut bit_reader)?; // bit_depth_luma_minus8 + read_exp_golomb(&mut bit_reader)?; // bit_depth_chroma_minus8 + read_exp_golomb(&mut bit_reader)?; // log2_max_pic_order_cnt_lsb_minus4 + let sps_sub_layer_ordering_info_present_flag = bit_reader.read_bit()?; + + if sps_sub_layer_ordering_info_present_flag { + for _ in 0..=sps_max_sub_layers_minus1 { + read_exp_golomb(&mut bit_reader)?; // sps_max_dec_pic_buffering_minus1 + read_exp_golomb(&mut bit_reader)?; // sps_max_num_reorder_pics + read_exp_golomb(&mut bit_reader)?; // sps_max_latency_increase_plus1 + } + }; + + read_exp_golomb(&mut bit_reader)?; // log2_min_luma_coding_block_size_minus3 + read_exp_golomb(&mut bit_reader)?; // log2_diff_max_min_luma_coding_block_size + read_exp_golomb(&mut bit_reader)?; // log2_min_transform_block_size_minus2 + read_exp_golomb(&mut bit_reader)?; // log2_diff_max_min_transform_block_size + read_exp_golomb(&mut bit_reader)?; // max_transform_hierarchy_depth_inter + read_exp_golomb(&mut bit_reader)?; // max_transform_hierarchy_depth_intra + + let scaling_list_enabled_flag = bit_reader.read_bit()?; + if scaling_list_enabled_flag { + let sps_scaling_list_data_present_flag = bit_reader.read_bit()?; + if sps_scaling_list_data_present_flag { + for size_id in 0..4 { + let mut matrix_id = 0; + while matrix_id < 6 { + let scaling_list_pred_mode_flag = bit_reader.read_bit()?; + if !scaling_list_pred_mode_flag { + read_exp_golomb(&mut bit_reader)?; // scaling_list_pred_matrix_id_delta + } else { + let coef_num = 64.min(1 << (4 + (size_id << 1))); + let mut next_coef = 8; + if size_id > 1 { + let scaling_list_dc_coef_minus8 = + read_signed_exp_golomb(&mut bit_reader)?; + next_coef = 8 + scaling_list_dc_coef_minus8; + } + for _ in 0..coef_num { + let scaling_list_delta_coef = + read_signed_exp_golomb(&mut bit_reader)?; + next_coef = (next_coef + scaling_list_delta_coef + 256) % 256; + } + } + matrix_id += if size_id == 3 { 3 } else { 1 }; + } + } + } + } + + bit_reader.seek_bits(1)?; // amp_enabled_flag + bit_reader.seek_bits(1)?; // sample_adaptive_offset_enabled_flag + + if bit_reader.read_bit()? { + // pcm_enabled_flag + bit_reader.seek_bits(4)?; // pcm_sample_bit_depth_luma_minus1 + bit_reader.seek_bits(4)?; // pcm_sample_bit_depth_chroma_minus1 + read_exp_golomb(&mut bit_reader)?; // log2_min_pcm_luma_coding_block_size_minus3 + read_exp_golomb(&mut bit_reader)?; // log2_diff_max_min_pcm_luma_coding_block_size + bit_reader.seek_bits(1)?; // pcm_loop_filter_disabled_flag + } + + let num_short_term_ref_pic_sets = read_exp_golomb(&mut bit_reader)?; + let mut num_delta_pocs = vec![0; num_short_term_ref_pic_sets as usize]; + for st_rps_idx in 0..num_short_term_ref_pic_sets { + if st_rps_idx != 0 && bit_reader.read_bit()? { + bit_reader.seek_bits(1)?; + read_exp_golomb(&mut bit_reader)?; // delta_rps_sign + + num_delta_pocs[st_rps_idx as usize] = 0; + + for _ in 0..num_delta_pocs[(st_rps_idx - 1) as usize] { + let used_by_curr_pic_flag = bit_reader.read_bit()?; + let use_delta_flag = if !used_by_curr_pic_flag { + bit_reader.read_bit()? // use_delta_flag + } else { + false + }; + + if used_by_curr_pic_flag || use_delta_flag { + num_delta_pocs[st_rps_idx as usize] += 1; + } + } + } else { + let num_negative_pics = read_exp_golomb(&mut bit_reader)?; + let num_positive_pics = read_exp_golomb(&mut bit_reader)?; + + num_delta_pocs[st_rps_idx as usize] = num_negative_pics + num_positive_pics; + for _ in 0..num_negative_pics { + read_exp_golomb(&mut bit_reader)?; // delta_poc_s0_minus1 + bit_reader.seek_bits(1)?; // used_by_curr_pic_s0_flag + } + for _ in 0..num_positive_pics { + read_exp_golomb(&mut bit_reader)?; // delta_poc_s1_minus1 + bit_reader.seek_bits(1)?; // used_by_curr_pic_s1_flag + } + } + } + + let long_term_ref_pics_present_flag = bit_reader.read_bit()?; + if long_term_ref_pics_present_flag { + let num_long_term_ref_pics_sps = read_exp_golomb(&mut bit_reader)?; + for _ in 0..num_long_term_ref_pics_sps { + read_exp_golomb(&mut bit_reader)?; // lt_ref_pic_poc_lsb_sps + bit_reader.seek_bits(1)?; // used_by_curr_pic_lt_sps_flag + } + } + + bit_reader.seek_bits(1)?; // sps_temporal_mvp_enabled_flag + bit_reader.seek_bits(1)?; // strong_intra_smoothing_enabled_flag + let vui_parameters_present_flag = bit_reader.read_bit()?; + + let mut color_config = None; + + let mut frame_rate = 0.0; + if vui_parameters_present_flag { + let aspect_ratio_info_present_flag = bit_reader.read_bit()?; + if aspect_ratio_info_present_flag { + let aspect_ratio_idc = bit_reader.read_bits(8)?; + if aspect_ratio_idc == 255 { + bit_reader.seek_bits(16)?; // sar_width + bit_reader.seek_bits(16)?; // sar_height + } + } + + let overscan_info_present_flag = bit_reader.read_bit()?; + if overscan_info_present_flag { + bit_reader.seek_bits(1)?; // overscan_appropriate_flag + } + + let video_signal_type_present_flag = bit_reader.read_bit()?; + if video_signal_type_present_flag { + bit_reader.seek_bits(3)?; // video_format + let full_range = bit_reader.read_bit()?; // video_full_range_flag + let color_primaries; + let transfer_characteristics; + let matrix_coefficients; + + let colour_description_present_flag = bit_reader.read_bit()?; + if colour_description_present_flag { + color_primaries = bit_reader.read_u8()?; // colour_primaries + transfer_characteristics = bit_reader.read_u8()?; // transfer_characteristics + matrix_coefficients = bit_reader.read_u8()?; // matrix_coeffs + } else { + color_primaries = 2; // Unspecified + transfer_characteristics = 2; // Unspecified + matrix_coefficients = 2; // Unspecified + } + + color_config = Some(ColorConfig { + full_range, + color_primaries, + transfer_characteristics, + matrix_coefficients, + }); + } + + let chroma_loc_info_present_flag = bit_reader.read_bit()?; + if chroma_loc_info_present_flag { + read_exp_golomb(&mut bit_reader)?; // chroma_sample_loc_type_top_field + read_exp_golomb(&mut bit_reader)?; // chroma_sample_loc_type_bottom_field + } + + bit_reader.seek_bits(1)?; + bit_reader.seek_bits(1)?; + bit_reader.seek_bits(1)?; + let default_display_window_flag = bit_reader.read_bit()?; + + if default_display_window_flag { + read_exp_golomb(&mut bit_reader)?; // def_disp_win_left_offset + read_exp_golomb(&mut bit_reader)?; // def_disp_win_right_offset + read_exp_golomb(&mut bit_reader)?; // def_disp_win_top_offset + read_exp_golomb(&mut bit_reader)?; // def_disp_win_bottom_offset + } + + let vui_timing_info_present_flag = bit_reader.read_bit()?; + if vui_timing_info_present_flag { + let num_units_in_tick = bit_reader.read_bits(32)?; // vui_num_units_in_tick + let time_scale = bit_reader.read_bits(32)?; // vui_time_scale + + frame_rate = time_scale as f64 / num_units_in_tick as f64; + } + } + + Ok(Sps { + width, + height, + frame_rate, + color_config, + }) + } +} diff --git a/video/codec/h265/src/tests.rs b/video/codec/h265/src/tests.rs new file mode 100644 index 00000000..0d1e86d6 --- /dev/null +++ b/video/codec/h265/src/tests.rs @@ -0,0 +1,100 @@ +use std::io; + +use bytes::Bytes; + +use crate::{ + sps::{ColorConfig, Sps}, + HEVCDecoderConfigurationRecord, NaluType, +}; + +#[test] +fn test_sps_parse() { + let data = b"B\x01\x01\x01@\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\xa0\x01@ \x05\xa1e\x95R\x90\x84d_\xf8\xc0Z\x80\x80\x80\x82\0\0\x03\0\x02\0\0\x03\x01 \xc0\x0b\xbc\xa2\0\x02bX\0\x011-\x08".to_vec(); + + let sps = Sps::parse(Bytes::from(data.to_vec())).unwrap(); + assert_eq!( + sps, + Sps { + color_config: Some(ColorConfig { + full_range: false, + color_primaries: 1, + matrix_coefficients: 1, + transfer_characteristics: 1, + }), + frame_rate: 144.0, + width: 2560, + height: 1440, + } + ); +} + +#[test] +fn test_config_demux() { + // h265 config + let data = Bytes::from(b"\x01\x01@\0\0\0\x90\0\0\0\0\0\x99\xf0\0\xfc\xfd\xf8\xf8\0\0\x0f\x03 \0\x01\0\x18@\x01\x0c\x01\xff\xff\x01@\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\x95@\x90!\0\x01\0=B\x01\x01\x01@\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\xa0\x01@ \x05\xa1e\x95R\x90\x84d_\xf8\xc0Z\x80\x80\x80\x82\0\0\x03\0\x02\0\0\x03\x01 \xc0\x0b\xbc\xa2\0\x02bX\0\x011-\x08\"\0\x01\0\x07D\x01\xc0\x93|\x0c\xc9".to_vec()); + + let config = HEVCDecoderConfigurationRecord::demux(&mut io::Cursor::new(data)).unwrap(); + + assert_eq!(config.configuration_version, 1); + assert_eq!(config.general_profile_space, 0); + assert!(!config.general_tier_flag); + assert_eq!(config.general_profile_idc, 1); + assert_eq!(config.general_profile_compatibility_flags, 64); + assert_eq!(config.general_constraint_indicator_flags, 144); + assert_eq!(config.general_level_idc, 153); + assert_eq!(config.min_spatial_segmentation_idc, 0); + assert_eq!(config.parallelism_type, 0); + assert_eq!(config.chroma_format_idc, 1); + assert_eq!(config.bit_depth_luma_minus8, 0); + assert_eq!(config.bit_depth_chroma_minus8, 0); + assert_eq!(config.avg_frame_rate, 0); + assert_eq!(config.constant_frame_rate, 0); + assert_eq!(config.num_temporal_layers, 1); + assert!(config.temporal_id_nested); + assert_eq!(config.length_size_minus_one, 3); + assert_eq!(config.arrays.len(), 3); + + let vps = &config.arrays[0]; + assert!(!vps.array_completeness); + assert_eq!(vps.nal_unit_type, NaluType::Vps); + assert_eq!(vps.nalus.len(), 1); + + let sps = &config.arrays[1]; + assert!(!sps.array_completeness); + assert_eq!(sps.nal_unit_type, NaluType::Sps); + assert_eq!(sps.nalus.len(), 1); + let sps = Sps::parse(sps.nalus[0].clone()).unwrap(); + assert_eq!( + sps, + Sps { + color_config: Some(ColorConfig { + full_range: false, + color_primaries: 1, + matrix_coefficients: 1, + transfer_characteristics: 1, + }), + frame_rate: 144.0, + width: 2560, + height: 1440, + } + ); + + let pps = &config.arrays[2]; + assert!(!pps.array_completeness); + assert_eq!(pps.nal_unit_type, NaluType::Pps); + assert_eq!(pps.nalus.len(), 1); +} + +#[test] +fn test_config_mux() { + let data = Bytes::from(b"\x01\x01@\0\0\0\x90\0\0\0\0\0\x99\xf0\0\xfc\xfd\xf8\xf8\0\0\x0f\x03 \0\x01\0\x18@\x01\x0c\x01\xff\xff\x01@\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\x95@\x90!\0\x01\0=B\x01\x01\x01@\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\xa0\x01@ \x05\xa1e\x95R\x90\x84d_\xf8\xc0Z\x80\x80\x80\x82\0\0\x03\0\x02\0\0\x03\x01 \xc0\x0b\xbc\xa2\0\x02bX\0\x011-\x08\"\0\x01\0\x07D\x01\xc0\x93|\x0c\xc9".to_vec()); + + let config = HEVCDecoderConfigurationRecord::demux(&mut io::Cursor::new(data.clone())).unwrap(); + + assert_eq!(config.size(), data.len() as u64); + + let mut buf = Vec::new(); + config.mux(&mut buf).unwrap(); + + assert_eq!(buf, data.to_vec()); +} diff --git a/video/container/flv/Cargo.toml b/video/container/flv/Cargo.toml new file mode 100644 index 00000000..4972dbea --- /dev/null +++ b/video/container/flv/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flv" +version = "0.0.1" +edition = "2021" + +[dependencies] +byteorder = "1" +bytes = "1" +num-traits = "0" +num-derive = "0" + +bytesio = { path = "../../bytesio" } +av1 = { path = "../../codec/av1" } +h264 = { path = "../../codec/h264" } +h265 = { path = "../../codec/h265" } +aac = { path = "../../codec/aac" } +amf0 = { path = "../../utils/amf0" } diff --git a/video/container/flv/src/define.rs b/video/container/flv/src/define.rs new file mode 100644 index 00000000..d71f4c22 --- /dev/null +++ b/video/container/flv/src/define.rs @@ -0,0 +1,310 @@ +use amf0::Amf0Value; +use av1::AV1CodecConfigurationRecord; +use bytes::Bytes; +use h264::AVCDecoderConfigurationRecord; +use h265::HEVCDecoderConfigurationRecord; +use num_derive::FromPrimitive; + +#[derive(Debug, Clone, PartialEq)] +/// FLV File +/// Is a container which has a header and a series of tags. +/// Defined in the FLV specification. Chapter 1 - FLV File Format +pub struct Flv { + pub header: FlvHeader, + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// FLV Header +/// Is a 9-byte header which contains information about the FLV file. +/// Defined in the FLV specification. Chapter 1 - The FLV Header +pub struct FlvHeader { + pub version: u8, + pub has_audio: bool, + pub has_video: bool, + pub data_offset: u32, + pub extra: Bytes, +} + +#[derive(Debug, Clone, PartialEq)] +/// FLV Tag +/// This is a container for the actual media data. +/// Defined in the FLV specification. Chapter 1 - FLV Tags +pub struct FlvTag { + pub timestamp: u32, + pub stream_id: u32, + pub data: FlvTagData, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Tag Type +/// Defined in the FLV specification. Chapter 1 - FLV tags +pub enum FlvTagType { + Audio = 8, + Video = 9, + ScriptData = 18, +} + +#[derive(Debug, Clone, PartialEq)] +/// FLV Tag Data +/// This is a container for the actual media data. +/// This enum contains the data for the different types of tags. +/// Defined in the FLV specification. Chapter 1 - FLV tags +pub enum FlvTagData { + /// AudioData defined in the FLV specification. Chapter 1 - FLV Audio Tags + Audio { + sound_rate: SoundRate, + sound_size: SoundSize, + sound_type: SoundType, + data: FlvTagAudioData, + }, + /// VideoData defined in the FLV specification. Chapter 1 - FLV Video Tags + Video { + frame_type: FrameType, + data: FlvTagVideoData, + }, + /// ScriptData defined in the FLV specification. Chapter 1 - FLV Data Tags + ScriptData { name: String, data: Vec }, + /// Data we don't know how to parse + Unknown { tag_type: u8, data: Bytes }, +} + +#[derive(Debug, Clone, PartialEq)] +/// FLV Tag Audio Data +/// This is a container for audio data. +/// This enum contains the data for the different types of audio tags. +/// Defined in the FLV specification. Chapter 1 - FLV Audio Tags +pub enum FlvTagAudioData { + /// AAC Audio Packet defined in the FLV specification. Chapter 1 - AACAUDIODATA + Aac(AacPacket), + /// Data we don't know how to parse + Unknown { sound_format: u8, data: Bytes }, +} + +#[derive(Debug, Clone, PartialEq)] +/// AAC Packet +/// This is a container for aac data. +/// This enum contains the data for the different types of aac packets. +/// Defined in the FLV specification. Chapter 1 - AACAUDIODATA +pub enum AacPacket { + /// AAC Raw + Raw(Bytes), + /// AAC Sequence Header + SequenceHeader(Bytes), + /// Data we don't know how to parse + Unknown { aac_packet_type: u8, data: Bytes }, +} + +#[derive(Debug, Clone, PartialEq)] +/// FLV Tag Video Data +/// This is a container for video data. +/// This enum contains the data for the different types of video tags. +/// Defined in the FLV specification. Chapter 1 - FLV Video Tags +pub enum FlvTagVideoData { + /// AVC Video Packet defined in the FLV specification. Chapter 1 - AVCVIDEOPACKET + Avc(AvcPacket), + /// Enhanced Packet + Enhanced(EnhancedPacket), + /// Data we don't know how to parse + Unknown { codec_id: u8, data: Bytes }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EnhancedPacket { + /// Metadata + Metadata(Bytes), + /// Sequence End + SequenceEnd, + /// Av1 Video Packet + Av1(Av1Packet), + /// Hevc (H.265) Video Packet + Hevc(HevcPacket), + /// We don't know how to parse it + Unknown { + packet_type: u8, + video_codec: [u8; 4], + data: Bytes, + }, +} + +#[derive(Debug, Clone, PartialEq)] +/// AVC Packet +pub enum AvcPacket { + /// AVC NALU + Nalu { composition_time: u32, data: Bytes }, + /// AVC Sequence Header + SequenceHeader(AVCDecoderConfigurationRecord), + /// AVC End of Sequence + EndOfSequence, + /// AVC Unknown (we don't know how to parse it) + Unknown { + avc_packet_type: u8, + composition_time: u32, + data: Bytes, + }, +} + +#[derive(Debug, Clone, PartialEq)] +/// HEVC Packet +pub enum HevcPacket { + SequenceStart(HEVCDecoderConfigurationRecord), + Nalu { + composition_time: Option, + data: Bytes, + }, +} + +#[derive(Debug, Clone, PartialEq)] +/// AV1 Packet +/// This is a container for av1 data. +/// This enum contains the data for the different types of av1 packets. +pub enum Av1Packet { + SequenceStart(AV1CodecConfigurationRecord), + Raw(Bytes), +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum EnhancedPacketType { + SequenceStart = 0x00, + CodedFrames = 0x01, + SequenceEnd = 0x02, + CodedFramesX = 0x03, + Metadata = 0x04, + Mpeg2SequenceStart = 0x05, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Sound Codec Id +/// Defined in the FLV specification. Chapter 1 - AudioTags +/// The SoundCodecID indicates the codec used to encode the sound. +pub(crate) enum SoundCodecId { + LinearPcmPlatformEndian = 0x0, + Adpcm = 0x1, + Mp3 = 0x2, + LinearPcmLittleEndian = 0x3, + Nellymoser16KhzMono = 0x4, + Nellymoser8KhzMono = 0x5, + Nellymoser = 0x6, + G711ALaw = 0x7, + G711MuLaw = 0x8, + Reserved = 0x9, + Aac = 0xA, + Speex = 0xB, + Mp38Khz = 0xE, + DeviceSpecificSound = 0xF, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Sound Rate +/// Defined in the FLV specification. Chapter 1 - AudioTags +/// The SoundRate indicates the sampling rate of the audio data. +pub enum SoundRate { + Hz5500 = 0x0, + Hz11000 = 0x1, + Hz22000 = 0x2, + Hz44000 = 0x3, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Sound Size +/// Defined in the FLV specification. Chapter 1 - AudioTags +/// The SoundSize indicates the size of each sample in the audio data. +pub enum SoundSize { + Bit8 = 0x0, + Bit16 = 0x1, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Sound Type +/// Defined in the FLV specification. Chapter 1 - AudioTags +/// The SoundType indicates the number of channels in the audio data. +pub enum SoundType { + Mono = 0x0, + Stereo = 0x1, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV AAC Packet Type +/// Defined in the FLV specification. Chapter 1 - AACAUDIODATA +/// The AACPacketType indicates the type of data in the AACAUDIODATA. +pub(crate) enum AacPacketType { + SeqHdr = 0x0, + Raw = 0x1, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Video Codec ID +/// Defined in the FLV specification. Chapter 1 - VideoTags +/// The codec ID indicates which codec is used to encode the video data. +pub(crate) enum VideoCodecId { + SorensonH263 = 0x2, + ScreenVideo = 0x3, + On2VP6 = 0x4, + On2VP6WithAlphaChannel = 0x5, + ScreenVideoVersion2 = 0x6, + Avc = 0x7, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VideoFourCC { + Av1, + Vp9, + Hevc, + Unknown([u8; 4]), +} + +impl From<[u8; 4]> for VideoFourCC { + fn from(fourcc: [u8; 4]) -> Self { + match &fourcc { + b"av01" => VideoFourCC::Av1, + b"vp09" => VideoFourCC::Vp9, + b"hvc1" => VideoFourCC::Hevc, + _ => VideoFourCC::Unknown(fourcc), + } + } +} + +impl From for [u8; 4] { + fn from(fourcc: VideoFourCC) -> Self { + match fourcc { + VideoFourCC::Av1 => *b"av01", + VideoFourCC::Vp9 => *b"vp09", + VideoFourCC::Hevc => *b"hvc1", + VideoFourCC::Unknown(fourcc) => fourcc, + } + } +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV Frame Type +/// Defined in the FLV specification. Chapter 1 - VideoTags +/// The frame type is used to determine if the video frame is a keyframe, an interframe or disposable interframe. +pub enum FrameType { + Unknown = 0x0, + Keyframe = 0x1, + Interframe = 0x2, + DisposableInterframe = 0x3, + GeneratedKeyframe = 0x4, + VideoInfoOrCommandFrame = 0x5, + EnhancedMetadata = 0xF, +} + +#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq, Eq)] +#[repr(u8)] +/// FLV AVC Packet Type +/// Defined in the FLV specification. Chapter 1 - AVCVIDEODATA +/// The AVC packet type is used to determine if the video data is a sequence header or a NALU. +pub(crate) enum AvcPacketType { + SeqHdr = 0x0, + Nalu = 0x1, + EndOfSequence = 0x2, +} diff --git a/video/container/flv/src/errors.rs b/video/container/flv/src/errors.rs new file mode 100644 index 00000000..fa1d3fb1 --- /dev/null +++ b/video/container/flv/src/errors.rs @@ -0,0 +1,52 @@ +use std::{fmt, io}; + +#[derive(Debug)] +pub enum FlvDemuxerError { + IO(io::Error), + Amf0Read(amf0::Amf0ReadError), + InvalidFlvHeader, + InvalidScriptDataName, + InvalidEnhancedPacketType(u8), + InvalidSoundRate(u8), + InvalidSoundSize(u8), + InvalidSoundType(u8), + InvalidFrameType(u8), +} + +impl From for FlvDemuxerError { + fn from(error: io::Error) -> Self { + Self::IO(error) + } +} + +impl From for FlvDemuxerError { + fn from(value: amf0::Amf0ReadError) -> Self { + Self::Amf0Read(value) + } +} + +impl std::fmt::Display for FlvDemuxerError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::IO(error) => write!(f, "io error: {}", error), + Self::Amf0Read(error) => write!(f, "amf0 read error: {}", error), + Self::InvalidFlvHeader => write!(f, "invalid flv header"), + Self::InvalidScriptDataName => write!(f, "invalid script data name"), + Self::InvalidEnhancedPacketType(error) => { + write!(f, "invalid enhanced packet type: {}", error) + } + Self::InvalidSoundRate(error) => { + write!(f, "invalid sound rate: {}", error) + } + Self::InvalidSoundSize(error) => { + write!(f, "invalid sound size: {}", error) + } + Self::InvalidSoundType(error) => { + write!(f, "invalid sound type: {}", error) + } + Self::InvalidFrameType(error) => { + write!(f, "invalid frame type: {}", error) + } + } + } +} diff --git a/video/container/flv/src/flv.rs b/video/container/flv/src/flv.rs new file mode 100644 index 00000000..f4c3e6ea --- /dev/null +++ b/video/container/flv/src/flv.rs @@ -0,0 +1,302 @@ +use std::io::{self, Read}; + +use amf0::{Amf0Reader, Amf0Value}; +use av1::AV1CodecConfigurationRecord; +use bytesio::bytes_reader::BytesCursor; +use h264::AVCDecoderConfigurationRecord; +use h265::HEVCDecoderConfigurationRecord; +use num_traits::FromPrimitive; + +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::{Buf, Bytes}; + +use crate::{ + define::Flv, AacPacket, AacPacketType, Av1Packet, AvcPacket, AvcPacketType, EnhancedPacket, + EnhancedPacketType, FlvDemuxerError, FlvHeader, FlvTag, FlvTagAudioData, FlvTagData, + FlvTagType, FlvTagVideoData, FrameType, HevcPacket, SoundCodecId, SoundRate, SoundSize, + SoundType, VideoCodecId, VideoFourCC, +}; + +impl Flv { + /// Demux a FLV file. + pub fn demux(reader: &mut io::Cursor) -> Result { + let header = FlvHeader::demux(reader)?; + + let mut tags = Vec::new(); + while reader.has_remaining() { + reader.read_u32::()?; // previous tag size + + if !reader.has_remaining() { + break; + } + + let tag = FlvTag::demux(reader)?; + tags.push(tag); + } + + Ok(Flv { header, tags }) + } +} + +impl FlvHeader { + pub fn demux(reader: &mut io::Cursor) -> Result { + let mut flv_bytes = [0; 3]; + reader.read_exact(&mut flv_bytes)?; + + if &flv_bytes != b"FLV" { + return Err(FlvDemuxerError::InvalidFlvHeader); + } + + let version = reader.read_u8()?; + let flags = reader.read_u8()?; + + let has_audio = flags & 0b0000_0100 != 0; + let has_video = flags & 0b0000_0001 != 0; + + let data_offset = reader.read_u32::()?; + + let remaining = data_offset - reader.position() as u32; + let extra = reader.read_slice(remaining as usize)?; + + Ok(FlvHeader { + data_offset, + has_audio, + has_video, + version, + extra, + }) + } +} + +impl FlvTag { + pub fn demux(reader: &mut io::Cursor) -> Result { + let tag_type = reader.read_u8()?; + let data_size = reader.read_u24::()?; + let timestamp = reader.read_u24::()? | (reader.read_u8()? as u32) << 24; + let stream_id = reader.read_u24::()?; + + let data = reader.read_slice(data_size as usize)?; + + let data = FlvTagData::demux(tag_type, data)?; + + Ok(FlvTag { + timestamp, + stream_id, + data, + }) + } +} + +impl FlvTagData { + pub fn demux(tag_type: u8, data: Bytes) -> Result { + let mut reader = io::Cursor::new(data); + + match FlvTagType::from_u8(tag_type) { + Some(FlvTagType::Audio) => { + let flags = reader.read_u8()?; + + let sound_format = (flags & 0b1111_0000) >> 4; + + let sound_rate = (flags & 0b0000_1100) >> 2; + let sound_rate = SoundRate::from_u8(sound_rate) + .ok_or_else(|| FlvDemuxerError::InvalidSoundRate(sound_rate))?; + + let sound_size = (flags & 0b0000_0010) >> 1; + let sound_size = SoundSize::from_u8(sound_size) + .ok_or_else(|| FlvDemuxerError::InvalidSoundSize(sound_size))?; + + let sound_type = flags & 0b0000_0001; + let sound_type = SoundType::from_u8(sound_type) + .ok_or_else(|| FlvDemuxerError::InvalidSoundType(sound_type))?; + + let data = FlvTagAudioData::demux(sound_format, &mut reader)?; + + Ok(FlvTagData::Audio { + sound_rate, + sound_size, + sound_type, + data, + }) + } + Some(FlvTagType::Video) => { + let flags = reader.read_u8()?; + let mut frame_type = flags >> 4; + + let mut is_enhanced = false; + let codec_id = flags & 0b0000_1111; + + if frame_type & 0b1000 != 0 { + // Enhanced Flv Tag + frame_type &= 0b0111; + is_enhanced = true; + + if codec_id == EnhancedPacketType::Metadata as u8 { + frame_type = FrameType::EnhancedMetadata as u8; + } + } + + let frame_type = FrameType::from_u8(frame_type) + .ok_or_else(|| FlvDemuxerError::InvalidFrameType(frame_type))?; + + Ok(FlvTagData::Video { + frame_type, + data: if is_enhanced { + FlvTagVideoData::demux_enhanced(codec_id, &mut reader)? + } else { + FlvTagVideoData::demux(codec_id, &mut reader)? + }, + }) + } + Some(FlvTagType::ScriptData) => { + let values = Amf0Reader::new(reader.get_remaining()).read_all()?; + + let name = match values.get(0) { + Some(Amf0Value::String(name)) => name, + _ => return Err(FlvDemuxerError::InvalidScriptDataName), + }; + + Ok(FlvTagData::ScriptData { + name: name.clone(), + data: values.into_iter().skip(1).collect(), + }) + } + None => Ok(FlvTagData::Unknown { + tag_type, + data: reader.get_remaining(), + }), + } + } +} + +impl FlvTagAudioData { + pub fn demux( + sound_format: u8, + reader: &mut io::Cursor, + ) -> Result { + match SoundCodecId::from_u8(sound_format) { + Some(SoundCodecId::Aac) => { + let aac_packet_type = reader.read_u8()?; + Ok(Self::Aac(AacPacket::demux(aac_packet_type, reader)?)) + } + _ => Ok(Self::Unknown { + sound_format, + data: reader.get_remaining(), + }), + } + } +} + +impl AacPacket { + pub fn demux( + aac_packet_type: u8, + reader: &mut io::Cursor, + ) -> Result { + match AacPacketType::from_u8(aac_packet_type) { + Some(AacPacketType::SeqHdr) => Ok(Self::SequenceHeader(reader.get_remaining())), + Some(AacPacketType::Raw) => Ok(Self::Raw(reader.get_remaining())), + _ => Ok(Self::Unknown { + aac_packet_type, + data: reader.get_remaining(), + }), + } + } +} + +impl FlvTagVideoData { + pub fn demux(codec_id: u8, reader: &mut io::Cursor) -> Result { + match VideoCodecId::from_u8(codec_id) { + Some(VideoCodecId::Avc) => { + let avc_packet_type = reader.read_u8()?; + Ok(Self::Avc(AvcPacket::demux(avc_packet_type, reader)?)) + } + _ => Ok(Self::Unknown { + codec_id, + data: reader.get_remaining(), + }), + } + } + + pub fn demux_enhanced( + packet_type: u8, + reader: &mut io::Cursor, + ) -> Result { + // In the enhanced spec the codec id is the packet type + let packet_type = EnhancedPacketType::from_u8(packet_type) + .ok_or_else(|| FlvDemuxerError::InvalidEnhancedPacketType(packet_type))?; + let mut video_codec = [0; 4]; + reader.read_exact(&mut video_codec)?; + let video_codec = VideoFourCC::from(video_codec); + + match packet_type { + EnhancedPacketType::SequenceEnd => { + return Ok(Self::Enhanced(EnhancedPacket::SequenceEnd)) + } + EnhancedPacketType::Metadata => { + return Ok(Self::Enhanced(EnhancedPacket::Metadata( + reader.get_remaining(), + ))) + } + _ => {} + } + + match (video_codec, packet_type) { + (VideoFourCC::Av1, EnhancedPacketType::SequenceStart) => { + Ok(Self::Enhanced(EnhancedPacket::Av1( + Av1Packet::SequenceStart(AV1CodecConfigurationRecord::demux(reader)?), + ))) + } + (VideoFourCC::Av1, EnhancedPacketType::CodedFrames) => Ok(Self::Enhanced( + EnhancedPacket::Av1(Av1Packet::Raw(reader.get_remaining())), + )), + (VideoFourCC::Hevc, EnhancedPacketType::SequenceStart) => { + Ok(Self::Enhanced(EnhancedPacket::Hevc( + HevcPacket::SequenceStart(HEVCDecoderConfigurationRecord::demux(reader)?), + ))) + } + (VideoFourCC::Hevc, EnhancedPacketType::CodedFrames) => { + let composition_time = reader.read_i24::()?; + Ok(Self::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { + composition_time: Some(composition_time), + data: reader.get_remaining(), + }))) + } + (VideoFourCC::Hevc, EnhancedPacketType::CodedFramesX) => { + Ok(Self::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { + composition_time: None, + data: reader.get_remaining(), + }))) + } + _ => Ok(Self::Enhanced(EnhancedPacket::Unknown { + packet_type: packet_type as u8, + video_codec: video_codec.into(), + data: reader.get_remaining(), + })), + } + } +} + +impl AvcPacket { + pub fn demux( + avc_packet_type: u8, + reader: &mut io::Cursor, + ) -> Result { + match AvcPacketType::from_u8(avc_packet_type) { + Some(AvcPacketType::SeqHdr) => { + reader.read_u24::()?; // composition time (always 0) + Ok(Self::SequenceHeader(AVCDecoderConfigurationRecord::demux( + reader, + )?)) + } + Some(AvcPacketType::Nalu) => Ok(Self::Nalu { + composition_time: reader.read_u24::()?, + data: reader.get_remaining(), + }), + Some(AvcPacketType::EndOfSequence) => Ok(Self::EndOfSequence), + _ => Ok(Self::Unknown { + avc_packet_type, + composition_time: reader.read_u24::()?, + data: reader.get_remaining(), + }), + } + } +} diff --git a/video/container/flv/src/lib.rs b/video/container/flv/src/lib.rs new file mode 100644 index 00000000..06599900 --- /dev/null +++ b/video/container/flv/src/lib.rs @@ -0,0 +1,9 @@ +mod define; +mod errors; +mod flv; + +pub use define::*; +pub use errors::FlvDemuxerError; + +#[cfg(test)] +mod tests; diff --git a/video/container/flv/src/tests/demuxer.rs b/video/container/flv/src/tests/demuxer.rs new file mode 100644 index 00000000..5cd75e82 --- /dev/null +++ b/video/container/flv/src/tests/demuxer.rs @@ -0,0 +1,810 @@ +use std::{io, path::PathBuf}; + +use aac::{AudioObjectType, AudioSpecificConfig}; +use av1::{seq::SequenceHeaderObu, ObuHeader}; +use bytes::Bytes; +use bytesio::bit_reader::BitReader; +use h264::{Sps, SpsExtended}; + +use crate::{ + AacPacket, Av1Packet, AvcPacket, EnhancedPacket, Flv, FlvTagAudioData, FlvTagData, + FlvTagVideoData, FrameType, HevcPacket, SoundRate, SoundSize, SoundType, +}; + +#[test] +fn test_demux_flv_avc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = Bytes::from(std::fs::read(dir.join("avc_aac.flv")).expect("failed to read file")); + let mut reader = io::Cursor::new(data); + + let flv = Flv::demux(&mut reader).expect("failed to demux flv"); + + assert_eq!(flv.header.version, 1); + assert!(flv.header.has_audio); + assert!(flv.header.has_video); + assert_eq!(flv.header.data_offset, 9); + assert_eq!(flv.header.extra.len(), 0); + + let mut tags = flv.tags.into_iter(); + + // Metadata tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a metadata tag + let script_data = match tag.data { + FlvTagData::ScriptData { name, data } => { + assert_eq!(name, "onMetaData"); + data + } + _ => panic!("expected script data"), + }; + + // Script data should be an AMF0 object + let object = match &script_data[0] { + amf0::Amf0Value::Object(object) => object, + _ => panic!("expected object"), + }; + + // Should have a audio sample size property + let audio_sample_size = match object.get("audiosamplesize") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample size"), + }; + + assert_eq!(audio_sample_size, &16.0); + + // Should have a audio sample rate property + let audio_sample_rate = match object.get("audiosamplerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample rate"), + }; + + assert_eq!(audio_sample_rate, &48000.0); + + // Should have a stereo property + let stereo = match object.get("stereo") { + Some(amf0::Amf0Value::Boolean(boolean)) => boolean, + _ => panic!("expected stereo"), + }; + + assert_eq!(stereo, &true); + + // Should have an audio codec id property + let audio_codec_id = match object.get("audiocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio codec id"), + }; + + assert_eq!(audio_codec_id, &10.0); // AAC + + // Should have a video codec id property + let video_codec_id = match object.get("videocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected video codec id"), + }; + + assert_eq!(video_codec_id, &7.0); // AVC + + // Should have a duration property + let duration = match object.get("duration") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected duration"), + }; + + assert_eq!(duration, &1.088); // 1.088 seconds + + // Should have a width property + let width = match object.get("width") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected width"), + }; + + assert_eq!(width, &3840.0); + + // Should have a height property + let height = match object.get("height") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected height"), + }; + + assert_eq!(height, &2160.0); + + // Should have a framerate property + let framerate = match object.get("framerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected framerate"), + }; + + assert_eq!(framerate, &60.0); + + // Should have a videodatarate property + match object.get("videodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected videodatarate"), + }; + + // Should have a audiodatarate property + match object.get("audiodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audiodatarate"), + }; + + // Should have a minor version property + let minor_version = match object.get("minor_version") { + Some(amf0::Amf0Value::String(number)) => number, + _ => panic!("expected minor version"), + }; + + assert_eq!(minor_version, "512"); + + // Should have a major brand property + let major_brand = match object.get("major_brand") { + Some(amf0::Amf0Value::String(string)) => string, + _ => panic!("expected major brand"), + }; + + assert_eq!(major_brand, "iso5"); + + // Should have a compatible_brands property + let compatible_brands = match object.get("compatible_brands") { + Some(amf0::Amf0Value::String(string)) => string, + _ => panic!("expected compatible brands"), + }; + + assert_eq!(compatible_brands, "iso5iso6mp41"); + } + + // Video Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a video tag + let (frame_type, video_data) = match tag.data { + FlvTagData::Video { frame_type, data } => (frame_type, data), + _ => panic!("expected video data"), + }; + + assert_eq!(frame_type, FrameType::Keyframe); + + // Video data should be an AVC sequence header + let avc_decoder_configuration_record = match video_data { + FlvTagVideoData::Avc(AvcPacket::SequenceHeader(data)) => data, + _ => panic!("expected avc sequence header"), + }; + + // The avc sequence header should be able to be decoded into an avc decoder configuration record + assert_eq!(avc_decoder_configuration_record.profile_indication, 100); + assert_eq!(avc_decoder_configuration_record.profile_compatibility, 0); + assert_eq!(avc_decoder_configuration_record.level_indication, 51); // 5.1 + assert_eq!(avc_decoder_configuration_record.length_size_minus_one, 3); + assert_eq!(avc_decoder_configuration_record.sps.len(), 1); + assert_eq!(avc_decoder_configuration_record.pps.len(), 1); + assert_eq!(avc_decoder_configuration_record.extended_config, None); + + let sps = &avc_decoder_configuration_record.sps[0]; + // SPS should be able to be decoded into a sequence parameter set + let sps = Sps::parse(sps.clone()).expect("expected sequence parameter set"); + + assert_eq!(sps.profile_idc, 100); + assert_eq!(sps.level_idc, 51); + assert_eq!(sps.width, 3840); + assert_eq!(sps.height, 2160); + assert_eq!(sps.frame_rate, 60.0); + + assert_eq!( + sps.ext, + Some(SpsExtended { + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + }) + ) + } + + // Audio Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + let (data, sound_rate, sound_size, sound_type) = match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => (data, sound_rate, sound_size, sound_type), + _ => panic!("expected audio data"), + }; + + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + + // Audio data should be an AAC sequence header + let data = match data { + FlvTagAudioData::Aac(AacPacket::SequenceHeader(data)) => data, + _ => panic!("expected aac sequence header"), + }; + + // The aac sequence header should be able to be decoded into an aac decoder configuration record + let aac_decoder_configuration_record = + AudioSpecificConfig::parse(data).expect("expected aac decoder configuration record"); + + assert_eq!( + aac_decoder_configuration_record.audio_object_type, + AudioObjectType::AacLowComplexity + ); + assert_eq!(aac_decoder_configuration_record.sampling_frequency, 48000); + assert_eq!(aac_decoder_configuration_record.channel_configuration, 2); + } + + // Rest of the tags should be video / audio data + let mut last_timestamp = 0; + let mut read_seq_end = false; + for tag in tags { + assert!(tag.timestamp >= last_timestamp); + assert_eq!(tag.stream_id, 0); + + last_timestamp = tag.timestamp; + + match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => { + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + match data { + FlvTagAudioData::Aac(AacPacket::Raw(data)) => data, + _ => panic!("expected aac raw packet"), + }; + } + FlvTagData::Video { frame_type, data } => { + match frame_type { + FrameType::Keyframe => (), + FrameType::Interframe => (), + _ => panic!("expected keyframe or interframe"), + } + + match data { + FlvTagVideoData::Avc(AvcPacket::Nalu { .. }) => assert!(!read_seq_end), + FlvTagVideoData::Avc(AvcPacket::EndOfSequence) => { + assert!(!read_seq_end); + read_seq_end = true; + } + _ => panic!("expected avc nalu packet: {:?}", data), + }; + } + _ => panic!("expected audio data"), + }; + } + + assert!(read_seq_end); +} + +#[test] +fn test_demux_flv_av1_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = Bytes::from(std::fs::read(dir.join("av1_aac.flv")).expect("failed to read file")); + let mut reader = io::Cursor::new(data); + + let flv = Flv::demux(&mut reader).expect("failed to demux flv"); + + assert_eq!(flv.header.version, 1); + assert!(flv.header.has_audio); + assert!(flv.header.has_video); + assert_eq!(flv.header.data_offset, 9); + assert_eq!(flv.header.extra.len(), 0); + + let mut tags = flv.tags.into_iter(); + + // Metadata tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a metadata tag + let script_data = match tag.data { + FlvTagData::ScriptData { name, data } => { + assert_eq!(name, "onMetaData"); + data + } + _ => panic!("expected script data"), + }; + + // Script data should be an AMF0 object + let object = match &script_data[0] { + amf0::Amf0Value::Object(object) => object, + _ => panic!("expected object"), + }; + + // Should have a audio sample size property + let audio_sample_size = match object.get("audiosamplesize") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample size"), + }; + + assert_eq!(audio_sample_size, &16.0); + + // Should have a audio sample rate property + let audio_sample_rate = match object.get("audiosamplerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample rate"), + }; + + assert_eq!(audio_sample_rate, &48000.0); + + // Should have a stereo property + let stereo = match object.get("stereo") { + Some(amf0::Amf0Value::Boolean(boolean)) => boolean, + _ => panic!("expected stereo"), + }; + + assert_eq!(stereo, &true); + + // Should have an audio codec id property + let audio_codec_id = match object.get("audiocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio codec id"), + }; + + assert_eq!(audio_codec_id, &10.0); // AAC + + // Should have a video codec id property + let video_codec_id = match object.get("videocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected video codec id"), + }; + + assert_eq!(video_codec_id, &7.0); // AVC + + // Should have a duration property + let duration = match object.get("duration") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected duration"), + }; + + assert_eq!(duration, &0.0); // 0 seconds (this was a live stream) + + // Should have a width property + let width = match object.get("width") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected width"), + }; + + assert_eq!(width, &2560.0); + + // Should have a height property + let height = match object.get("height") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected height"), + }; + + assert_eq!(height, &1440.0); + + // Should have a framerate property + let framerate = match object.get("framerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected framerate"), + }; + + assert_eq!(framerate, &144.0); + + // Should have a videodatarate property + match object.get("videodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected videodatarate"), + }; + + // Should have a audiodatarate property + match object.get("audiodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audiodatarate"), + }; + } + + // Audio Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + let (data, sound_rate, sound_size, sound_type) = match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => (data, sound_rate, sound_size, sound_type), + _ => panic!("expected audio data"), + }; + + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + + // Audio data should be an AAC sequence header + let data = match data { + FlvTagAudioData::Aac(AacPacket::SequenceHeader(data)) => data, + _ => panic!("expected aac sequence header"), + }; + + // The aac sequence header should be able to be decoded into an aac decoder configuration record + let aac_decoder_configuration_record = + AudioSpecificConfig::parse(data).expect("expected aac decoder configuration record"); + + assert_eq!( + aac_decoder_configuration_record.audio_object_type, + AudioObjectType::AacLowComplexity + ); + assert_eq!(aac_decoder_configuration_record.sampling_frequency, 48000); + assert_eq!(aac_decoder_configuration_record.channel_configuration, 2); + } + + // Video Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a video tag + let (frame_type, video_data) = match tag.data { + FlvTagData::Video { frame_type, data } => (frame_type, data), + _ => panic!("expected video data"), + }; + + assert_eq!(frame_type, FrameType::Keyframe); + + // Video data should be an AVC sequence header + let config = match video_data { + FlvTagVideoData::Enhanced(EnhancedPacket::Av1(Av1Packet::SequenceStart(config))) => { + config + } + _ => panic!("expected av1 sequence header found {:?}", video_data), + }; + + assert_eq!(config.version, 1); + assert_eq!(config.chroma_sample_position, 0); + assert!(config.chroma_subsampling_x); // 5.1 + assert!(config.chroma_subsampling_y); + assert!(!config.high_bitdepth); + assert!(!config.twelve_bit); + + let (header, data) = + ObuHeader::parse(&mut BitReader::new(io::Cursor::new(&config.config_obu))) + .expect("expected obu header"); + + let seq_obu = SequenceHeaderObu::parse(header, data).expect("expected sequence obu"); + + assert_eq!(seq_obu.max_frame_height, 1440); + assert_eq!(seq_obu.max_frame_width, 2560); + } + + // Rest of the tags should be video / audio data + let mut last_timestamp = 0; + let mut read_seq_end = false; + for tag in tags { + assert!(tag.timestamp >= last_timestamp || tag.timestamp == 0); // Timestamps should be monotonically increasing or 0 + assert_eq!(tag.stream_id, 0); + + if tag.timestamp != 0 { + last_timestamp = tag.timestamp; + } + + match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => { + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + match data { + FlvTagAudioData::Aac(AacPacket::Raw(data)) => data, + _ => panic!("expected aac raw packet"), + }; + } + FlvTagData::Video { frame_type, data } => { + match frame_type { + FrameType::Keyframe => (), + FrameType::Interframe => (), + _ => panic!("expected keyframe or interframe"), + } + + match data { + FlvTagVideoData::Enhanced(EnhancedPacket::Av1(Av1Packet::Raw(_))) => { + assert!(!read_seq_end) + } + FlvTagVideoData::Enhanced(EnhancedPacket::SequenceEnd) => { + assert!(!read_seq_end); + read_seq_end = true; + } + _ => panic!("expected av1 raw packet: {:?}", data), + }; + } + _ => panic!("expected audio data"), + }; + } + + assert!(read_seq_end); +} + +#[test] +fn test_demux_flv_hevc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = Bytes::from(std::fs::read(dir.join("hevc_aac.flv")).expect("failed to read file")); + let mut reader = io::Cursor::new(data); + + let flv = Flv::demux(&mut reader).expect("failed to demux flv"); + + assert_eq!(flv.header.version, 1); + assert!(flv.header.has_audio); + assert!(flv.header.has_video); + assert_eq!(flv.header.data_offset, 9); + assert_eq!(flv.header.extra.len(), 0); + + let mut tags = flv.tags.into_iter(); + + // Metadata tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a metadata tag + let script_data = match tag.data { + FlvTagData::ScriptData { name, data } => { + assert_eq!(name, "onMetaData"); + data + } + _ => panic!("expected script data"), + }; + + // Script data should be an AMF0 object + let object = match &script_data[0] { + amf0::Amf0Value::Object(object) => object, + _ => panic!("expected object"), + }; + + // Should have a audio sample size property + let audio_sample_size = match object.get("audiosamplesize") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample size"), + }; + + assert_eq!(audio_sample_size, &16.0); + + // Should have a audio sample rate property + let audio_sample_rate = match object.get("audiosamplerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio sample rate"), + }; + + assert_eq!(audio_sample_rate, &48000.0); + + // Should have a stereo property + let stereo = match object.get("stereo") { + Some(amf0::Amf0Value::Boolean(boolean)) => boolean, + _ => panic!("expected stereo"), + }; + + assert_eq!(stereo, &true); + + // Should have an audio codec id property + let audio_codec_id = match object.get("audiocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audio codec id"), + }; + + assert_eq!(audio_codec_id, &10.0); // AAC + + // Should have a video codec id property + let video_codec_id = match object.get("videocodecid") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected video codec id"), + }; + + assert_eq!(video_codec_id, &7.0); // AVC + + // Should have a duration property + let duration = match object.get("duration") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected duration"), + }; + + assert_eq!(duration, &0.0); // 0 seconds (this was a live stream) + + // Should have a width property + let width = match object.get("width") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected width"), + }; + + assert_eq!(width, &2560.0); + + // Should have a height property + let height = match object.get("height") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected height"), + }; + + assert_eq!(height, &1440.0); + + // Should have a framerate property + let framerate = match object.get("framerate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected framerate"), + }; + + assert_eq!(framerate, &144.0); + + // Should have a videodatarate property + match object.get("videodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected videodatarate"), + }; + + // Should have a audiodatarate property + match object.get("audiodatarate") { + Some(amf0::Amf0Value::Number(number)) => number, + _ => panic!("expected audiodatarate"), + }; + } + + // Audio Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + let (data, sound_rate, sound_size, sound_type) = match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => (data, sound_rate, sound_size, sound_type), + _ => panic!("expected audio data"), + }; + + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + + // Audio data should be an AAC sequence header + let data = match data { + FlvTagAudioData::Aac(AacPacket::SequenceHeader(data)) => data, + _ => panic!("expected aac sequence header"), + }; + + // The aac sequence header should be able to be decoded into an aac decoder configuration record + let aac_decoder_configuration_record = + AudioSpecificConfig::parse(data).expect("expected aac decoder configuration record"); + + assert_eq!( + aac_decoder_configuration_record.audio_object_type, + AudioObjectType::AacLowComplexity + ); + assert_eq!(aac_decoder_configuration_record.sampling_frequency, 48000); + assert_eq!(aac_decoder_configuration_record.channel_configuration, 2); + } + + // Video Sequence Header Tag + { + let tag = tags.next().expect("expected tag"); + assert_eq!(tag.timestamp, 0); + assert_eq!(tag.stream_id, 0); + + // This is a video tag + let (frame_type, video_data) = match tag.data { + FlvTagData::Video { frame_type, data } => (frame_type, data), + _ => panic!("expected video data"), + }; + + assert_eq!(frame_type, FrameType::Keyframe); + + // Video data should be an AVC sequence header + let config = match video_data { + FlvTagVideoData::Enhanced(EnhancedPacket::Hevc(HevcPacket::SequenceStart(config))) => { + config + } + _ => panic!("expected hevc sequence header found {:?}", video_data), + }; + + assert_eq!(config.configuration_version, 1); + assert_eq!(config.avg_frame_rate, 0); + assert_eq!(config.constant_frame_rate, 0); + assert_eq!(config.num_temporal_layers, 1); + + // We should be able to find a SPS NAL unit in the sequence header + let Some(sps) = config.arrays.iter().find(|a| a.nal_unit_type == h265::NaluType::Sps).and_then(|v| v.nalus.get(0)) else { + panic!("expected sps"); + }; + + // We should be able to find a PPS NAL unit in the sequence header + let Some(_) = config.arrays.iter().find(|a| a.nal_unit_type == h265::NaluType::Pps).and_then(|v| v.nalus.get(0)) else { + panic!("expected pps"); + }; + + // We should be able to decode the SPS NAL unit + let sps = h265::Sps::parse(sps.clone()).expect("expected sps"); + + assert_eq!(sps.frame_rate, 144.0); + assert_eq!(sps.width, 2560); + assert_eq!(sps.height, 1440); + assert_eq!( + sps.color_config, + Some(h265::ColorConfig { + full_range: false, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + }) + ) + } + + // Rest of the tags should be video / audio data + let mut last_timestamp = 0; + let mut read_seq_end = false; + for tag in tags { + assert!(tag.timestamp >= last_timestamp || tag.timestamp == 0); // Timestamps should be monotonically increasing or 0 + assert_eq!(tag.stream_id, 0); + + if tag.timestamp != 0 { + last_timestamp = tag.timestamp; + } + + match tag.data { + FlvTagData::Audio { + data, + sound_rate, + sound_size, + sound_type, + } => { + assert_eq!(sound_rate, SoundRate::Hz44000); + assert_eq!(sound_size, SoundSize::Bit16); + assert_eq!(sound_type, SoundType::Stereo); + match data { + FlvTagAudioData::Aac(AacPacket::Raw(data)) => data, + _ => panic!("expected aac raw packet"), + }; + } + FlvTagData::Video { frame_type, data } => { + match frame_type { + FrameType::Keyframe => (), + FrameType::Interframe => (), + _ => panic!("expected keyframe or interframe"), + } + + match data { + FlvTagVideoData::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { + .. + })) => assert!(!read_seq_end), + FlvTagVideoData::Enhanced(EnhancedPacket::SequenceEnd) => { + assert!(!read_seq_end); + read_seq_end = true; + } + _ => panic!("expected hevc nalu packet: {:?}", data), + }; + } + _ => panic!("expected audio data"), + }; + } + + assert!(read_seq_end); +} diff --git a/video/container/flv/src/tests/error.rs b/video/container/flv/src/tests/error.rs new file mode 100644 index 00000000..56b3a691 --- /dev/null +++ b/video/container/flv/src/tests/error.rs @@ -0,0 +1,31 @@ +use crate::FlvDemuxerError; + +#[test] +fn test_error_display() { + let error = FlvDemuxerError::InvalidFrameType(0); + assert_eq!(error.to_string(), "invalid frame type: 0"); + + let error = FlvDemuxerError::IO(std::io::Error::new(std::io::ErrorKind::Other, "test")); + assert_eq!(error.to_string(), "io error: test"); + + let error = FlvDemuxerError::Amf0Read(amf0::Amf0ReadError::UnknownMarker(0)); + assert_eq!(error.to_string(), "amf0 read error: unknown marker: 0"); + + let error = FlvDemuxerError::InvalidFlvHeader; + assert_eq!(error.to_string(), "invalid flv header"); + + let error = FlvDemuxerError::InvalidScriptDataName; + assert_eq!(error.to_string(), "invalid script data name"); + + let error = FlvDemuxerError::InvalidEnhancedPacketType(0); + assert_eq!(error.to_string(), "invalid enhanced packet type: 0"); + + let error = FlvDemuxerError::InvalidSoundRate(0); + assert_eq!(error.to_string(), "invalid sound rate: 0"); + + let error = FlvDemuxerError::InvalidSoundSize(0); + assert_eq!(error.to_string(), "invalid sound size: 0"); + + let error = FlvDemuxerError::InvalidSoundType(0); + assert_eq!(error.to_string(), "invalid sound type: 0"); +} diff --git a/video/container/flv/src/tests/mod.rs b/video/container/flv/src/tests/mod.rs new file mode 100644 index 00000000..9d93f26c --- /dev/null +++ b/video/container/flv/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod demuxer; +mod error; diff --git a/video/container/mp4/Cargo.toml b/video/container/mp4/Cargo.toml new file mode 100644 index 00000000..73cf7ed5 --- /dev/null +++ b/video/container/mp4/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "mp4" +version = "0.0.1" +edition = "2021" + +[dependencies] +byteorder = "1" +bytes = "1" +fixed = "1" +casey = "0" +paste = "1" + +bytesio = { path = "../../bytesio/", default-features = false, features = []} +h264 = { path = "../../codec/h264" } +h265 = { path = "../../codec/h265" } +av1 = { path = "../../codec/av1" } +aac = { path = "../../codec/aac" } + +[dev-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/video/container/mp4/src/boxes/header.rs b/video/container/mp4/src/boxes/header.rs new file mode 100644 index 00000000..ae0c51ff --- /dev/null +++ b/video/container/mp4/src/boxes/header.rs @@ -0,0 +1,86 @@ +use std::{ + fmt::{Debug, Formatter}, + io::{self, Read}, +}; + +use byteorder::{ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use bytesio::bytes_reader::BytesCursor; + +#[derive(Clone, PartialEq)] +pub struct BoxHeader { + pub box_type: [u8; 4], +} + +impl BoxHeader { + pub fn new(box_type: [u8; 4]) -> Self { + Self { box_type } + } +} + +impl Debug for BoxHeader { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoxHeader") + .field("box_type", &Bytes::from(self.box_type[..].to_vec())) + .finish() + } +} + +impl BoxHeader { + pub fn demux(reader: &mut io::Cursor) -> Result<(Self, Bytes), io::Error> { + let size = reader.read_u32::()? as u64; + + let mut box_type: [u8; 4] = [0; 4]; + reader.read_exact(&mut box_type)?; + + let offset = if size == 1 { 16 } else { 8 }; + + let size = if size == 1 { + reader.read_u64::()? + } else { + size + }; + + // We already read 8 bytes, so we need to subtract that from the size. + let data = reader.read_slice((size - offset) as usize)?; + + Ok((Self { box_type }, data)) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FullBoxHeader { + pub header: BoxHeader, + pub version: u8, + pub flags: u32, +} + +impl FullBoxHeader { + pub fn new(box_type: [u8; 4], version: u8, flags: u32) -> Self { + Self { + header: BoxHeader::new(box_type), + version, + flags, + } + } + + pub fn demux(header: BoxHeader, reader: &mut io::Cursor) -> io::Result { + let version = reader.read_u8()?; + let flags = reader.read_u24::()?; + Ok(Self { + header, + version, + flags, + }) + } + + pub fn mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u8(self.version)?; + writer.write_u24::(self.flags)?; + Ok(()) + } + + pub const fn size(&self) -> u64 { + 1 + 3 + } +} diff --git a/video/container/mp4/src/boxes/macros.rs b/video/container/mp4/src/boxes/macros.rs new file mode 100644 index 00000000..8b35d0d6 --- /dev/null +++ b/video/container/mp4/src/boxes/macros.rs @@ -0,0 +1,123 @@ +macro_rules! match_helper { + ([size] $expr:expr, $($name:tt,)*) => { + match $expr { + $( + Self::$name(box_) => box_.size(), + )* + Self::Unknown((_, data)) => { + let size = data.len() as u64 + 8; + if size > u32::MAX as u64 { + size + 8 + } else { + size + } + } + } + }; + ([write] $expr:expr, $writer:expr, $($name:tt,)*) => { + match $expr { + $( + Self::$name(box_) => box_.mux($writer)?, + )* + Self::Unknown((header, data)) => { + let size = data.len() as u64 + 8; + if size > u32::MAX as u64 { + $writer.write_u32::(1)?; + } else { + $writer.write_u32::(size as u32)?; + } + $writer.write_all(&header.box_type)?; + if size > u32::MAX as u64 { + $writer.write_u64::(size)?; + } + $writer.write_all(data)?; + } + } + }; + ([parse] $expr:expr, $header:expr, $data:expr, $($name:tt,)*) => { + match $expr { + $( + &$name::NAME => Ok(Self::$name(<$name>::demux($header, $data)?)), + )* + _ => Ok(Self::Unknown(($header, $data))), + } + }; +} + +macro_rules! as_fn { + ($($type:tt,)*) => { + $( + paste! { + #[allow(dead_code)] + pub fn [](&self) -> Option<&$type> { + match self { + Self::$type(box_) => Some(box_), + _ => None, + } + } + } + )* + }; +} + +macro_rules! impl_from { + ($($type:tt,)*) => { + $( + impl From<$type> for DynBox { + fn from(box_: $type) -> Self { + Self::$type(box_) + } + } + )* + }; +} + +macro_rules! impl_box { + ($($type:tt,)*) => { + #[derive(Debug, Clone, PartialEq)] + pub enum DynBox { + $( + $type($type), + )* + Unknown((BoxHeader, Bytes)), + } + + impl DynBox { + pub fn size(&self) -> u64 { + match_helper!( + [size] self, + $($type,)* + ) + } + + pub fn mux(&self, writer: &mut W) -> io::Result<()> { + match_helper!( + [write] self, writer, + $($type,)* + ); + + Ok(()) + } + + pub fn demux(reader: &mut io::Cursor) -> io::Result { + let (header, data) = BoxHeader::demux(reader)?; + + match_helper!( + [parse] & header.box_type, + header, + data, + $($type,)* + ) + } + + as_fn!( + $($type,)* + ); + } + + + impl_from!( + $($type,)* + ); + }; +} diff --git a/video/container/mp4/src/boxes/mod.rs b/video/container/mp4/src/boxes/mod.rs new file mode 100644 index 00000000..003fe2fe --- /dev/null +++ b/video/container/mp4/src/boxes/mod.rs @@ -0,0 +1,40 @@ +use std::{fmt::Debug, io}; + +use byteorder::WriteBytesExt; +use bytes::Bytes; +use paste::paste; + +pub mod header; +mod traits; +pub mod types; + +#[macro_use] +mod macros; + +use header::BoxHeader; +pub use traits::BoxType; + +use crate::boxes::types::{ + av01::Av01, av1c::Av1C, avc1::Avc1, avcc::AvcC, btrt::Btrt, clap::Clap, co64::Co64, colr::Colr, + ctts::Ctts, dinf::Dinf, dref::Dref, edts::Edts, elst::Elst, esds::Esds, ftyp::Ftyp, hdlr::Hdlr, + hev1::Hev1, hmhd::Hmhd, hvcc::HvcC, mdat::Mdat, mdhd::Mdhd, mdia::Mdia, mehd::Mehd, mfhd::Mfhd, + minf::Minf, moof::Moof, moov::Moov, mp4a::Mp4a, mvex::Mvex, mvhd::Mvhd, nmhd::Nmhd, opus::Opus, + padb::Padb, pasp::Pasp, sbgp::Sbgp, sdtp::Sdtp, smhd::Smhd, stbl::Stbl, stco::Stco, stdp::Stdp, + stsc::Stsc, stsd::Stsd, stsh::Stsh, stss::Stss, stsz::Stsz, stts::Stts, stz2::Stz2, subs::Subs, + tfdt::Tfdt, tfhd::Tfhd, tkhd::Tkhd, traf::Traf, trak::Trak, trex::Trex, trun::Trun, url::Url, + vmhd::Vmhd, +}; + +#[rustfmt::skip] +impl_box!( + Ftyp, Moov, Mvhd, Mvex, Trak, Trex, + Mehd, Mdia, Tkhd, Edts, Elst, Mdhd, + Minf, Hdlr, Dinf, Stbl, Hmhd, Nmhd, + Smhd, Vmhd, Dref, Stsd, Stsz, Stsc, + Stco, Co64, Stts, Stss, Stz2, Stsh, + Ctts, Stdp, Sbgp, Subs, Padb, Sdtp, + Url, Avc1, Clap, Pasp, AvcC, Btrt, + Mp4a, Esds, Moof, Mfhd, Traf, Tfhd, + Tfdt, Trun, Mdat, Av01, Av1C, Colr, + Hev1, HvcC, Opus, +); diff --git a/video/container/mp4/src/boxes/traits.rs b/video/container/mp4/src/boxes/traits.rs new file mode 100644 index 00000000..eced3ad5 --- /dev/null +++ b/video/container/mp4/src/boxes/traits.rs @@ -0,0 +1,57 @@ +use std::io; + +use byteorder::WriteBytesExt; +use bytes::Bytes; + +use super::header::BoxHeader; + +pub trait BoxType { + const NAME: [u8; 4]; + + /// Parse a box from a byte stream. The basic header is already parsed. + fn demux(header: BoxHeader, data: Bytes) -> io::Result + where + Self: Sized; + + /// The size of the box without the basic header. + fn primitive_size(&self) -> u64; + + /// Write the box to a byte stream. The basic header is already written. + fn primitive_mux(&self, writer: &mut T) -> io::Result<()>; + + /// Write the box to a byte stream. + fn mux(&self, writer: &mut T) -> io::Result<()> { + self.validate()?; + + let size = self.size(); + if size > u32::MAX as u64 { + writer.write_u32::(1)?; + } else { + writer.write_u32::(size as u32)?; + } + + writer.write_all(&Self::NAME)?; + + if size > u32::MAX as u64 { + writer.write_u64::(size)?; + } + + self.primitive_mux(writer) + } + + /// Size of the box including the basic header. + fn size(&self) -> u64 { + let primitive_size = self.primitive_size() + 8; + + if primitive_size > u32::MAX as u64 { + primitive_size + 8 + } else { + primitive_size + } + } + + /// Validate the box. + fn validate(&self) -> io::Result<()> { + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/av01.rs b/video/container/mp4/src/boxes/types/av01.rs new file mode 100644 index 00000000..b850f0a6 --- /dev/null +++ b/video/container/mp4/src/boxes/types/av01.rs @@ -0,0 +1,142 @@ +use std::io; + +use av1::{seq::SequenceHeaderObu, ObuHeader, ObuType}; +use bytes::{Buf, Bytes}; +use bytesio::bit_reader::BitReader; + +use crate::{ + boxes::{header::BoxHeader, traits::BoxType, DynBox}, + codec::VideoCodec, +}; + +use super::{ + av1c::Av1C, + btrt::Btrt, + stsd::{SampleEntry, VisualSampleEntry}, +}; + +#[derive(Debug, Clone, PartialEq)] +/// AV1 Codec Box +/// https://aomediacodec.github.io/av1-isobmff/#av1sampleentry-section +pub struct Av01 { + pub header: BoxHeader, + pub visual_sample_entry: SampleEntry, + pub av1c: Av1C, + pub btrt: Option, + pub unknown: Vec, +} + +impl Av01 { + pub fn new( + visual_sample_entry: SampleEntry, + av1c: Av1C, + btrt: Option, + ) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + visual_sample_entry, + av1c, + btrt, + unknown: Vec::new(), + } + } + + pub fn codec(&self) -> io::Result { + let (header, data) = ObuHeader::parse(&mut BitReader::from( + self.av1c.av1_config.config_obu.clone(), + ))?; + + if header.obu_type != ObuType::SequenceHeader { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "av1c box is missing sequence header", + )); + } + + let seq_obu = SequenceHeaderObu::parse(header, data)?; + let op_point = &seq_obu.operating_points[0]; + + Ok(VideoCodec::Av1 { + profile: seq_obu.seq_profile, + level: op_point.seq_level_idx, + tier: op_point.seq_tier, + depth: seq_obu.color_config.bit_depth as u8, + monochrome: seq_obu.color_config.mono_chrome, + sub_sampling_x: seq_obu.color_config.subsampling_x, + sub_sampling_y: seq_obu.color_config.subsampling_y, + color_primaries: seq_obu.color_config.color_primaries, + transfer_characteristics: seq_obu.color_config.transfer_characteristics, + matrix_coefficients: seq_obu.color_config.matrix_coefficients, + full_range_flag: seq_obu.color_config.full_color_range, + }) + } +} + +impl BoxType for Av01 { + const NAME: [u8; 4] = *b"av01"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut visual_sample_entry = SampleEntry::::demux(&mut reader)?; + + let mut av1c = None; + let mut btrt = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + match dyn_box { + DynBox::Av1C(b) => { + av1c = Some(b); + } + DynBox::Btrt(b) => { + btrt = Some(b); + } + DynBox::Clap(b) => { + visual_sample_entry.extension.clap = Some(b); + } + DynBox::Pasp(b) => { + visual_sample_entry.extension.pasp = Some(b); + } + DynBox::Colr(b) => { + visual_sample_entry.extension.colr = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let av1c = av1c.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trak box is missing tkhd box") + })?; + + Ok(Self { + header, + visual_sample_entry, + av1c, + btrt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.visual_sample_entry.size() + + self.av1c.size() + + self.btrt.as_ref().map(|b| b.size()).unwrap_or(0) + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.visual_sample_entry.mux(writer)?; + self.av1c.mux(writer)?; + if let Some(btrt) = &self.btrt { + btrt.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/av1c.rs b/video/container/mp4/src/boxes/types/av1c.rs new file mode 100644 index 00000000..5b1ceee7 --- /dev/null +++ b/video/container/mp4/src/boxes/types/av1c.rs @@ -0,0 +1,43 @@ +use std::io; + +use av1::AV1CodecConfigurationRecord; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// AV1 Configuration Box +/// https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section +pub struct Av1C { + pub header: BoxHeader, + pub av1_config: AV1CodecConfigurationRecord, +} + +impl Av1C { + pub fn new(av1_config: AV1CodecConfigurationRecord) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + av1_config, + } + } +} + +impl BoxType for Av1C { + const NAME: [u8; 4] = *b"av1C"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + Ok(Self { + header, + av1_config: AV1CodecConfigurationRecord::demux(&mut reader)?, + }) + } + + fn primitive_size(&self) -> u64 { + self.av1_config.size() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.av1_config.mux(writer) + } +} diff --git a/video/container/mp4/src/boxes/types/avc1.rs b/video/container/mp4/src/boxes/types/avc1.rs new file mode 100644 index 00000000..3d45bdd6 --- /dev/null +++ b/video/container/mp4/src/boxes/types/avc1.rs @@ -0,0 +1,124 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::{ + boxes::{header::BoxHeader, traits::BoxType, DynBox}, + codec::VideoCodec, +}; + +use super::{ + avcc::AvcC, + btrt::Btrt, + stsd::{SampleEntry, VisualSampleEntry}, +}; + +#[derive(Debug, Clone, PartialEq)] +/// AVC Codec Box +/// ISO/IEC 14496-15:2022(E) - 6.5.3 +pub struct Avc1 { + pub header: BoxHeader, + pub visual_sample_entry: SampleEntry, + pub avcc: AvcC, + pub btrt: Option, + pub unknown: Vec, +} + +impl Avc1 { + pub fn new( + visual_sample_entry: SampleEntry, + avcc: AvcC, + btrt: Option, + ) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + visual_sample_entry, + avcc, + btrt, + unknown: Vec::new(), + } + } + + pub fn codec(&self) -> io::Result { + Ok(VideoCodec::Avc { + constraint_set: self + .avcc + .avc_decoder_configuration_record + .profile_compatibility, + level: self.avcc.avc_decoder_configuration_record.level_indication, + profile: self + .avcc + .avc_decoder_configuration_record + .profile_indication, + }) + } +} + +impl BoxType for Avc1 { + const NAME: [u8; 4] = *b"avc1"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut visual_sample_entry = SampleEntry::::demux(&mut reader)?; + + let mut avcc = None; + let mut btrt = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + match dyn_box { + DynBox::AvcC(b) => { + avcc = Some(b); + } + DynBox::Btrt(b) => { + btrt = Some(b); + } + DynBox::Clap(b) => { + visual_sample_entry.extension.clap = Some(b); + } + DynBox::Pasp(b) => { + visual_sample_entry.extension.pasp = Some(b); + } + DynBox::Colr(b) => { + visual_sample_entry.extension.colr = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let avcc = avcc.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trak box is missing tkhd box") + })?; + + Ok(Self { + header, + visual_sample_entry, + avcc, + btrt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.visual_sample_entry.size() + + self.avcc.size() + + self.btrt.as_ref().map(|b| b.size()).unwrap_or(0) + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.visual_sample_entry.mux(writer)?; + self.avcc.mux(writer)?; + if let Some(btrt) = &self.btrt { + btrt.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/avcc.rs b/video/container/mp4/src/boxes/types/avcc.rs new file mode 100644 index 00000000..2fe85a63 --- /dev/null +++ b/video/container/mp4/src/boxes/types/avcc.rs @@ -0,0 +1,43 @@ +use std::io; + +use bytes::Bytes; +use h264::AVCDecoderConfigurationRecord; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// AVC Configuration Box +/// ISO/IEC 14496-15:2022(E) - 5.4.2 +pub struct AvcC { + pub header: BoxHeader, + pub avc_decoder_configuration_record: AVCDecoderConfigurationRecord, +} + +impl AvcC { + pub fn new(avc_decoder_configuration_record: AVCDecoderConfigurationRecord) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + avc_decoder_configuration_record, + } + } +} + +impl BoxType for AvcC { + const NAME: [u8; 4] = *b"avcC"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + Ok(Self { + header, + avc_decoder_configuration_record: AVCDecoderConfigurationRecord::demux(&mut reader)?, + }) + } + + fn primitive_size(&self) -> u64 { + self.avc_decoder_configuration_record.size() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.avc_decoder_configuration_record.mux(writer) + } +} diff --git a/video/container/mp4/src/boxes/types/btrt.rs b/video/container/mp4/src/boxes/types/btrt.rs new file mode 100644 index 00000000..c5ef41fc --- /dev/null +++ b/video/container/mp4/src/boxes/types/btrt.rs @@ -0,0 +1,50 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// BitRate Box +/// ISO/IEC 14496-12:2022(E) - 8.5.2 +pub struct Btrt { + pub header: BoxHeader, + pub buffer_size_db: u32, + pub max_bitrate: u32, + pub avg_bitrate: u32, +} + +impl BoxType for Btrt { + const NAME: [u8; 4] = *b"btrt"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let buffer_size_db = reader.read_u32::()?; + let max_bitrate = reader.read_u32::()?; + let avg_bitrate = reader.read_u32::()?; + + Ok(Self { + header, + + buffer_size_db, + max_bitrate, + avg_bitrate, + }) + } + + fn primitive_size(&self) -> u64 { + 4 // buffer_size_db + + 4 // max_bitrate + + 4 // avg_bitrate + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u32::(self.buffer_size_db)?; + writer.write_u32::(self.max_bitrate)?; + writer.write_u32::(self.avg_bitrate)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/clap.rs b/video/container/mp4/src/boxes/types/clap.rs new file mode 100644 index 00000000..ed9c5f69 --- /dev/null +++ b/video/container/mp4/src/boxes/types/clap.rs @@ -0,0 +1,75 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// Clean Aperture Box +/// ISO/IEC 14496-12:2022(E) - 12.1.4.2 +pub struct Clap { + pub header: BoxHeader, + pub clean_aperture_width_n: u32, + pub clean_aperture_width_d: u32, + pub clean_aperture_height_n: u32, + pub clean_aperture_height_d: u32, + pub horiz_off_n: u32, + pub horiz_off_d: u32, + pub vert_off_n: u32, + pub vert_off_d: u32, +} + +impl BoxType for Clap { + const NAME: [u8; 4] = *b"clap"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let clean_aperture_width_n = reader.read_u32::()?; + let clean_aperture_width_d = reader.read_u32::()?; + let clean_aperture_height_n = reader.read_u32::()?; + let clean_aperture_height_d = reader.read_u32::()?; + let horiz_off_n = reader.read_u32::()?; + let horiz_off_d = reader.read_u32::()?; + let vert_off_n = reader.read_u32::()?; + let vert_off_d = reader.read_u32::()?; + + Ok(Self { + header, + + clean_aperture_width_n, + clean_aperture_width_d, + clean_aperture_height_n, + clean_aperture_height_d, + horiz_off_n, + horiz_off_d, + vert_off_n, + vert_off_d, + }) + } + + fn primitive_size(&self) -> u64 { + 4 // clean_aperture_width_n + + 4 // clean_aperture_width_d + + 4 // clean_aperture_height_n + + 4 // clean_aperture_height_d + + 4 // horiz_off_n + + 4 // horiz_off_d + + 4 // vert_off_n + + 4 // vert_off_d + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u32::(self.clean_aperture_width_n)?; + writer.write_u32::(self.clean_aperture_width_d)?; + writer.write_u32::(self.clean_aperture_height_n)?; + writer.write_u32::(self.clean_aperture_height_d)?; + writer.write_u32::(self.horiz_off_n)?; + writer.write_u32::(self.horiz_off_d)?; + writer.write_u32::(self.vert_off_n)?; + writer.write_u32::(self.vert_off_d)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/co64.rs b/video/container/mp4/src/boxes/types/co64.rs new file mode 100644 index 00000000..1a20b7c4 --- /dev/null +++ b/video/container/mp4/src/boxes/types/co64.rs @@ -0,0 +1,74 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Chunk Large Offset Box +/// ISO/IEC 14496-12:2022(E) - 8.7.5 +pub struct Co64 { + pub header: FullBoxHeader, + pub chunk_offset: Vec, +} + +impl BoxType for Co64 { + const NAME: [u8; 4] = *b"co64"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut chunk_offset = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let offset = reader.read_u32::()?; + chunk_offset.push(offset); + } + + Ok(Self { + header, + chunk_offset, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // entry_count + + (self.chunk_offset.len() as u64 * 4) // chunk_offset + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.chunk_offset.len() as u32)?; + for offset in &self.chunk_offset { + writer.write_u32::(*offset)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "co64 flags must be 0", + )); + } + + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "co64 version must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/colr.rs b/video/container/mp4/src/boxes/types/colr.rs new file mode 100644 index 00000000..da17ee30 --- /dev/null +++ b/video/container/mp4/src/boxes/types/colr.rs @@ -0,0 +1,100 @@ +use std::io::{self, Read}; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// Color Box +/// ISO/IEC 14496-12:2022(E) - 12.1.5 +pub struct Colr { + pub header: BoxHeader, + pub color_type: ColorType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ColorType { + Nclx { + color_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, + }, + Unknown(([u8; 4], Bytes)), +} + +impl Colr { + pub fn new(color_type: ColorType) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + color_type, + } + } +} + +impl BoxType for Colr { + const NAME: [u8; 4] = *b"colr"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut color_type = [0; 4]; + reader.read_exact(&mut color_type)?; + + let color_type = match &color_type { + b"nclx" => { + let color_primaries = reader.read_u16::()?; + let transfer_characteristics = reader.read_u16::()?; + let matrix_coefficients = reader.read_u16::()?; + let full_range_flag = (reader.read_u8()? >> 7) == 1; + + ColorType::Nclx { + color_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + } + } + _ => { + let pos = reader.position() as usize; + let data = reader.into_inner().slice(pos..); + ColorType::Unknown((color_type, data)) + } + }; + + Ok(Self { header, color_type }) + } + + fn primitive_size(&self) -> u64 { + let size = match &self.color_type { + ColorType::Nclx { .. } => 2 + 2 + 2 + 1, // 7 bytes + ColorType::Unknown((_, data)) => data.len() as u64, // unknown size + }; + + size + 4 // color type + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + match &self.color_type { + ColorType::Nclx { + color_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + } => { + writer.write_all(b"nclx")?; + writer.write_u16::(*color_primaries)?; + writer.write_u16::(*transfer_characteristics)?; + writer.write_u16::(*matrix_coefficients)?; + writer.write_u8(if *full_range_flag { 0x80 } else { 0x00 })?; + } + ColorType::Unknown((color_type, data)) => { + writer.write_all(color_type)?; + writer.write_all(data)?; + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/ctts.rs b/video/container/mp4/src/boxes/types/ctts.rs new file mode 100644 index 00000000..16a07fb5 --- /dev/null +++ b/video/container/mp4/src/boxes/types/ctts.rs @@ -0,0 +1,96 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Composition Time to Sample Box +/// ISO/IEC 14496-12:2022(E) - 8.6.1 +pub struct Ctts { + pub header: FullBoxHeader, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Entry in the Composition Time to Sample Box +pub struct CttsEntry { + pub sample_count: u32, + pub sample_offset: i64, +} + +impl BoxType for Ctts { + const NAME: [u8; 4] = *b"ctts"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + + let mut entries = Vec::with_capacity(entry_count as usize); + + for _ in 0..entry_count { + let sample_count = reader.read_u32::()?; + let sample_offset = if header.version == 1 { + reader.read_i32::()? as i64 + } else { + reader.read_u32::()? as i64 + }; + + entries.push(CttsEntry { + sample_count, + sample_offset, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // entry_count + + (self.entries.len() as u64 * 8) // entries + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + + for entry in &self.entries { + writer.write_u32::(entry.sample_count)?; + + if self.header.version == 1 { + writer.write_i32::(entry.sample_offset as i32)?; + } else { + writer.write_u32::(entry.sample_offset as u32)?; + } + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ctts flags must be 0", + )); + } + + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ctts version must be 0 or 1", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/dinf.rs b/video/container/mp4/src/boxes/types/dinf.rs new file mode 100644 index 00000000..c2f54a0e --- /dev/null +++ b/video/container/mp4/src/boxes/types/dinf.rs @@ -0,0 +1,74 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, types::dref::Dref, DynBox}; + +#[derive(Debug, Clone, PartialEq)] +/// Data Information Box +/// ISO/IEC 14496-12:2022(E) 8.7.1 +pub struct Dinf { + pub header: BoxHeader, + pub dref: Dref, + pub unknown: Vec, +} + +impl Default for Dinf { + fn default() -> Self { + Self::new() + } +} + +impl Dinf { + pub fn new() -> Self { + Self { + header: BoxHeader::new(Self::NAME), + dref: Dref::new(), + unknown: Vec::new(), + } + } +} + +impl BoxType for Dinf { + const NAME: [u8; 4] = *b"dinf"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut dref = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Dref(b) => { + dref = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + Ok(Self { + header, + dref: dref.unwrap(), + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.dref.size() + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.dref.mux(writer)?; + + for b in &self.unknown { + b.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/dref.rs b/video/container/mp4/src/boxes/types/dref.rs new file mode 100644 index 00000000..9c245ee3 --- /dev/null +++ b/video/container/mp4/src/boxes/types/dref.rs @@ -0,0 +1,92 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, + DynBox, +}; + +use super::url::Url; + +#[derive(Debug, Clone, PartialEq)] +/// Data Reference Box +/// IEO/IEC 14496-12:2022(E) - 8.7.2 +pub struct Dref { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Default for Dref { + fn default() -> Self { + Self::new() + } +} + +impl Dref { + pub fn new() -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries: vec![Url::new().into()], + } + } +} + +impl BoxType for Dref { + const NAME: [u8; 4] = *b"dref"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + + let mut entries = Vec::new(); + + for _ in 0..entry_count { + let dyn_box = DynBox::demux(&mut reader)?; + entries.push(dyn_box); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // entry_count + + self.entries.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + + for b in &self.entries { + b.mux(writer)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "dref version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "dref flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/edts.rs b/video/container/mp4/src/boxes/types/edts.rs new file mode 100644 index 00000000..cebd7b4a --- /dev/null +++ b/video/container/mp4/src/boxes/types/edts.rs @@ -0,0 +1,72 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::elst::Elst; + +#[derive(Debug, Clone, PartialEq)] +/// Edit Box +/// ISO/IEC 14496-12:2022(E) 8.6.5 +pub struct Edts { + pub header: BoxHeader, + pub elst: Option, + pub unknown: Vec, +} + +impl Edts { + pub fn new(elst: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + elst, + unknown: Vec::new(), + } + } +} + +impl BoxType for Edts { + const NAME: [u8; 4] = *b"edts"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + let mut elst = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Elst(b) => { + elst = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + Ok(Self { + header, + elst, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.elst.iter().map(|b| b.size()).sum::() + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + for b in &self.elst { + b.mux(writer)?; + } + + for b in &self.unknown { + b.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/elst.rs b/video/container/mp4/src/boxes/types/elst.rs new file mode 100644 index 00000000..da297170 --- /dev/null +++ b/video/container/mp4/src/boxes/types/elst.rs @@ -0,0 +1,144 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Edit List Box +/// ISO/IEC 14496-12:2022(E) - 8.6.6 +pub struct Elst { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Elst { + pub fn new(entries: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Entry in the Edit List Box +pub struct ElstEntry { + pub segment_duration: u64, + pub media_time: i64, + pub media_rate_integer: i16, + pub media_rate_fraction: i16, +} + +impl BoxType for Elst { + const NAME: [u8; 4] = *b"elst"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + + let mut entries = Vec::with_capacity(entry_count as usize); + + for _ in 0..entry_count { + let (segment_duration, media_time, media_rate_integer, media_rate_fraction) = + if header.version == 1 { + ( + reader.read_u64::()?, // segment_duration + reader.read_i64::()?, // media_time + reader.read_i16::()?, // media_rate_integer + reader.read_i16::()?, // media_rate_fraction + ) + } else { + ( + reader.read_u32::()? as u64, // segment_duration + reader.read_i32::()? as i64, // media_time + reader.read_i16::()?, // media_rate_integer + reader.read_i16::()?, // media_rate_fraction + ) + }; + + entries.push(ElstEntry { + segment_duration, + media_time, + media_rate_integer, + media_rate_fraction, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // entry_count + + (self.entries.len() as u64) * if self.header.version == 1 { + 8 + 8 + 2 + 2 // segment_duration + media_time + media_rate_integer + media_rate_fraction + } else { + 4 + 4 + 2 + 2 // segment_duration + media_time + media_rate_integer + media_rate_fraction + } + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + + for entry in &self.entries { + if self.header.version == 1 { + writer.write_u64::(entry.segment_duration)?; + writer.write_i64::(entry.media_time)?; + } else { + writer.write_u32::(entry.segment_duration as u32)?; + writer.write_i32::(entry.media_time as i32)?; + } + + writer.write_i16::(entry.media_rate_integer)?; + writer.write_i16::(entry.media_rate_fraction)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "elst: version 1 is not supported", + )); + } + + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "elst: version must be 0 or 1", + )); + } + + if self.header.version == 1 { + for entry in &self.entries { + if entry.segment_duration > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "elst: segment_duration must be u32", + )); + } + + if entry.media_time > i32::MAX as i64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "elst: media_time must be i32", + )); + } + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/header.rs b/video/container/mp4/src/boxes/types/esds/descriptor/header.rs new file mode 100644 index 00000000..8d763f2b --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/header.rs @@ -0,0 +1,68 @@ +use std::io; + +use byteorder::ReadBytesExt; +use bytes::Bytes; +use bytesio::bytes_reader::BytesCursor; + +#[derive(Debug, Clone, PartialEq)] +pub struct DescriptorHeader { + pub tag: DescriptorTag, +} + +impl DescriptorHeader { + pub fn new(tag: DescriptorTag) -> Self { + Self { tag } + } + + pub fn parse(reader: &mut io::Cursor) -> Result<(Self, Bytes), io::Error> { + let tag = reader.read_u8()?.into(); + + let mut size = 0_u32; + loop { + let byte = reader.read_u8()?; + size = (size << 7) | (byte & 0b01111111) as u32; + if (byte & 0b10000000) == 0 { + break; + } + } + + let data = reader.read_slice(size as usize)?; + + Ok((Self { tag }, data)) + } +} + +#[derive(Debug, Clone, PartialEq, Copy, Eq)] +/// Descriptor Tags +/// ISO/IEC 14496-1:2010(E) - 7.2.2 +pub enum DescriptorTag { + ESDescrTag, + DecoderConfigDescrTag, + DecSpecificInfoTag, + SLConfigDescrTag, + Unknown(u8), +} + +impl From for DescriptorTag { + fn from(tag: u8) -> Self { + match tag { + 0x03 => Self::ESDescrTag, + 0x04 => Self::DecoderConfigDescrTag, + 0x05 => Self::DecSpecificInfoTag, + 0x06 => Self::SLConfigDescrTag, + _ => Self::Unknown(tag), + } + } +} + +impl From for u8 { + fn from(tag: DescriptorTag) -> Self { + match tag { + DescriptorTag::ESDescrTag => 0x03, + DescriptorTag::DecoderConfigDescrTag => 0x04, + DescriptorTag::DecSpecificInfoTag => 0x05, + DescriptorTag::SLConfigDescrTag => 0x06, + DescriptorTag::Unknown(tag) => tag, + } + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/mod.rs b/video/container/mp4/src/boxes/types/esds/descriptor/mod.rs new file mode 100644 index 00000000..495bc8c4 --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/mod.rs @@ -0,0 +1,96 @@ +use std::io; + +use byteorder::WriteBytesExt; +use bytes::Bytes; + +use self::{ + header::DescriptorHeader, + traits::DescriptorType, + types::{ + decoder_config::DecoderConfigDescriptor, + decoder_specific_info::DecoderSpecificInfoDescriptor, es::EsDescriptor, + sl_config::SLConfigDescriptor, + }, +}; + +pub mod header; +pub mod traits; +pub mod types; + +#[derive(Debug, Clone, PartialEq)] +pub enum DynDescriptor { + Es(EsDescriptor), + DecoderConfig(DecoderConfigDescriptor), + DecoderSpecificInfo(DecoderSpecificInfoDescriptor), + SLConfig(SLConfigDescriptor), + Unknown(DescriptorHeader, Bytes), +} + +impl DynDescriptor { + pub fn demux(reader: &mut io::Cursor) -> io::Result { + let (header, data) = DescriptorHeader::parse(reader)?; + match header.tag { + EsDescriptor::TAG => Ok(Self::Es(EsDescriptor::demux(header, data)?)), + DecoderConfigDescriptor::TAG => Ok(Self::DecoderConfig( + DecoderConfigDescriptor::demux(header, data)?, + )), + DecoderSpecificInfoDescriptor::TAG => Ok(Self::DecoderSpecificInfo( + DecoderSpecificInfoDescriptor::demux(header, data)?, + )), + SLConfigDescriptor::TAG => Ok(Self::SLConfig(SLConfigDescriptor::demux(header, data)?)), + _ => Ok(Self::Unknown(header, data)), + } + } + + pub fn size(&self) -> u64 { + match self { + Self::Es(desc) => desc.size(), + Self::DecoderConfig(desc) => desc.size(), + Self::DecoderSpecificInfo(desc) => desc.size(), + Self::SLConfig(desc) => desc.size(), + Self::Unknown(_, data) => { + 1 // tag + + { + let mut size = data.len() as u32; + let mut bytes_required = 0; + loop { + size >>= 7; + bytes_required += 1; + if size == 0 { + break; + } + } + + bytes_required // number of bytes required to encode the size + } + + data.len() as u64 // data + } + } + } + + pub fn mux(&self, writer: &mut T) -> io::Result<()> { + match self { + Self::Es(desc) => desc.mux(writer), + Self::DecoderConfig(desc) => desc.mux(writer), + Self::DecoderSpecificInfo(desc) => desc.mux(writer), + Self::SLConfig(desc) => desc.mux(writer), + Self::Unknown(header, data) => { + writer.write_u8(header.tag.into())?; + let mut size = data.len() as u32; + loop { + let byte = (size & 0b01111111) as u8; + size >>= 7; + if size == 0 { + writer.write_u8(byte)?; + break; + } else { + writer.write_u8(byte | 0b10000000)?; + } + } + writer.write_all(data)?; + + Ok(()) + } + } + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/traits.rs b/video/container/mp4/src/boxes/types/esds/descriptor/traits.rs new file mode 100644 index 00000000..9c2972df --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/traits.rs @@ -0,0 +1,57 @@ +use std::io; + +use byteorder::WriteBytesExt; +use bytes::Bytes; + +use super::{header::DescriptorTag, DescriptorHeader}; + +pub trait DescriptorType { + const TAG: DescriptorTag; + + fn demux(header: DescriptorHeader, data: Bytes) -> io::Result + where + Self: Sized; + + fn primitive_size(&self) -> u64; + + fn size(&self) -> u64 { + let primitive_size = self.primitive_size(); + + primitive_size // size of the primitive data + + 1 // tag + + { + let mut size = primitive_size as u32; + let mut bytes_required = 0; + loop { + size >>= 7; + bytes_required += 1; + if size == 0 { + break; + } + } + + bytes_required // number of bytes required to encode the size + } as u64 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()>; + + fn mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u8(Self::TAG.into())?; + let size = self.primitive_size() as u32; + let mut size = size; + loop { + let byte = (size & 0b01111111) as u8; + size >>= 7; + if size == 0 { + writer.write_u8(byte)?; + break; + } else { + writer.write_u8(byte | 0b10000000)?; + } + } + self.primitive_mux(writer)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_config.rs b/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_config.rs new file mode 100644 index 00000000..a6af816e --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_config.rs @@ -0,0 +1,124 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{Buf, Bytes}; + +use crate::boxes::types::esds::descriptor::{ + header::{DescriptorHeader, DescriptorTag}, + traits::DescriptorType, + DynDescriptor, +}; + +use super::decoder_specific_info::DecoderSpecificInfoDescriptor; + +#[derive(Debug, Clone, PartialEq)] +/// Decoder Config Descriptor +/// ISO/IEC 14496-1:2010(E) - 7.2.6.6 +pub struct DecoderConfigDescriptor { + pub header: DescriptorHeader, + pub object_type_indication: u8, + pub stream_type: u8, // 6 bits + pub up_stream: bool, // 1 bit + pub reserved: u8, // 1 bit + pub buffer_size_db: u32, // 3 bytes + pub max_bitrate: u32, + pub avg_bitrate: u32, + pub decoder_specific_info: Option, + pub unknown: Vec, +} + +impl DecoderConfigDescriptor { + pub fn new( + object_type_indication: u8, + stream_type: u8, + max_bitrate: u32, + avg_bitrate: u32, + decoder_specific_info: Option, + ) -> Self { + Self { + header: DescriptorHeader::new(Self::TAG), + object_type_indication, + stream_type, + up_stream: false, + reserved: 1, + buffer_size_db: 0, + max_bitrate, + avg_bitrate, + decoder_specific_info, + unknown: Vec::new(), + } + } +} + +impl DescriptorType for DecoderConfigDescriptor { + const TAG: DescriptorTag = DescriptorTag::DecoderConfigDescrTag; + + fn demux(header: DescriptorHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let object_type_indication = reader.read_u8()?; + let byte = reader.read_u8()?; + let stream_type = byte >> 2; + let up_stream = (byte & 0b00000010) != 0; + let reserved = byte & 0b00000001; + let buffer_size_db = reader.read_u24::()?; + let max_bitrate = reader.read_u32::()?; + let avg_bitrate = reader.read_u32::()?; + + let mut decoder_specific_info = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let descriptor = DynDescriptor::demux(&mut reader)?; + match descriptor { + DynDescriptor::DecoderSpecificInfo(desc) => { + decoder_specific_info = Some(desc); + } + _ => unknown.push(descriptor), + } + } + + Ok(Self { + header, + object_type_indication, + stream_type, + up_stream, + reserved, + buffer_size_db, + max_bitrate, + avg_bitrate, + decoder_specific_info, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + 1 + 1 + + 3 + + 4 + + 4 + + self + .decoder_specific_info + .as_ref() + .map(|d| d.size()) + .unwrap_or(0) + + self.unknown.iter().map(|d| d.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u8(self.object_type_indication)?; + let byte = (self.stream_type << 2) | (self.up_stream as u8) << 1 | self.reserved; + writer.write_u8(byte)?; + writer.write_u24::(self.buffer_size_db)?; + writer.write_u32::(self.max_bitrate)?; + writer.write_u32::(self.avg_bitrate)?; + if let Some(decoder_specific_info) = &self.decoder_specific_info { + decoder_specific_info.mux(writer)?; + } + for descriptor in &self.unknown { + descriptor.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_specific_info.rs b/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_specific_info.rs new file mode 100644 index 00000000..98c9308b --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/types/decoder_specific_info.rs @@ -0,0 +1,34 @@ +use std::io; + +use bytes::Bytes; + +use crate::boxes::types::esds::descriptor::{ + header::{DescriptorHeader, DescriptorTag}, + traits::DescriptorType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Decoder Specific Info Descriptor +/// ISO/IEC 14496-1:2010(E) - 7.2.6.7 +pub struct DecoderSpecificInfoDescriptor { + pub header: DescriptorHeader, + pub data: Bytes, +} + +impl DescriptorType for DecoderSpecificInfoDescriptor { + const TAG: DescriptorTag = DescriptorTag::DecSpecificInfoTag; + + fn demux(header: DescriptorHeader, data: Bytes) -> io::Result { + Ok(Self { header, data }) + } + + fn primitive_size(&self) -> u64 { + self.data.len() as u64 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_all(&self.data)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/types/es.rs b/video/container/mp4/src/boxes/types/esds/descriptor/types/es.rs new file mode 100644 index 00000000..38884365 --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/types/es.rs @@ -0,0 +1,165 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{Buf, Bytes}; + +use crate::boxes::types::esds::descriptor::{ + header::{DescriptorHeader, DescriptorTag}, + traits::DescriptorType, + DynDescriptor, +}; + +use super::{decoder_config::DecoderConfigDescriptor, sl_config::SLConfigDescriptor}; + +#[derive(Debug, Clone, PartialEq)] +/// ES Descriptor +/// ISO/IEC 14496-1:2010(E) - 7.2.6.5 +pub struct EsDescriptor { + pub header: DescriptorHeader, + pub es_id: u16, + pub stream_priority: u8, // 5 bits + pub depends_on_es_id: Option, + pub url: Option, + pub ocr_es_id: Option, + pub decoder_config: Option, + pub sl_config: Option, + pub unknown: Vec, +} + +impl EsDescriptor { + pub fn new( + es_id: u16, + stream_priority: u8, + depends_on_es_id: Option, + url: Option, + ocr_es_id: Option, + decoder_config: Option, + sl_config: Option, + ) -> Self { + Self { + header: DescriptorHeader::new(Self::TAG), + es_id, + stream_priority, + depends_on_es_id, + url, + ocr_es_id, + decoder_config, + sl_config, + unknown: Vec::new(), + } + } +} + +impl DescriptorType for EsDescriptor { + const TAG: DescriptorTag = DescriptorTag::ESDescrTag; + + fn demux(header: DescriptorHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let es_id = reader.read_u16::()?; + let flag = reader.read_u8()?; + let stream_priority = flag & 0b00011111; + let stream_dependence_flag = (flag & 0b10000000) != 0; + let url_flag = (flag & 0b01000000) != 0; + let ocr_stream_flag = (flag & 0b00100000) != 0; + let depends_on_es_id = if stream_dependence_flag { + Some(reader.read_u16::()?) + } else { + None + }; + + let url = if url_flag { + let size = reader.read_u8()?; + let mut url = String::new(); + for _ in 0..size { + url.push(reader.read_u8()? as char); + } + Some(url) + } else { + None + }; + + let ocr_es_id = if ocr_stream_flag { + Some(reader.read_u16::()?) + } else { + None + }; + + let mut decoder_config = None; + let mut sl_config = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let descriptor = DynDescriptor::demux(&mut reader)?; + match descriptor { + DynDescriptor::DecoderConfig(desc) => { + decoder_config = Some(desc); + } + DynDescriptor::SLConfig(desc) => { + sl_config = Some(desc); + } + _ => unknown.push(descriptor), + } + } + + Ok(Self { + header, + es_id, + stream_priority, + depends_on_es_id, + url, + ocr_es_id, + decoder_config, + sl_config, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + 2 // es_id + + 1 // flag + + if self.depends_on_es_id.is_some() { 2 } else { 0 } + + if self.url.is_some() { 1 + self.url.as_ref().unwrap().len() as u64 } else { 0 } + + if self.ocr_es_id.is_some() { 2 } else { 0 } + + self.decoder_config.as_ref().map(|d| d.size()).unwrap_or(0) + + self.sl_config.as_ref().map(|d| d.size()).unwrap_or(0) + + self.unknown.iter().map(|d| d.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u16::(self.es_id)?; + let mut flag = self.stream_priority & 0b00011111; + if self.depends_on_es_id.is_some() { + flag |= 0b10000000; + } + if self.url.is_some() { + flag |= 0b01000000; + } + if self.ocr_es_id.is_some() { + flag |= 0b00100000; + } + writer.write_u8(flag)?; + if let Some(depends_on_es_id) = self.depends_on_es_id { + writer.write_u16::(depends_on_es_id)?; + } + if let Some(url) = &self.url { + writer.write_u8(url.len() as u8)?; + for c in url.chars() { + writer.write_u8(c as u8)?; + } + } + if let Some(ocr_es_id) = self.ocr_es_id { + writer.write_u16::(ocr_es_id)?; + } + if let Some(decoder_config) = &self.decoder_config { + decoder_config.mux(writer)?; + } + if let Some(sl_config) = &self.sl_config { + sl_config.mux(writer)?; + } + for descriptor in &self.unknown { + descriptor.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/types/mod.rs b/video/container/mp4/src/boxes/types/esds/descriptor/types/mod.rs new file mode 100644 index 00000000..752e5526 --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/types/mod.rs @@ -0,0 +1,4 @@ +pub mod decoder_config; +pub mod decoder_specific_info; +pub mod es; +pub mod sl_config; diff --git a/video/container/mp4/src/boxes/types/esds/descriptor/types/sl_config.rs b/video/container/mp4/src/boxes/types/esds/descriptor/types/sl_config.rs new file mode 100644 index 00000000..b7d14dce --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/descriptor/types/sl_config.rs @@ -0,0 +1,52 @@ +use std::io; + +use byteorder::WriteBytesExt; +use bytes::Bytes; + +use crate::boxes::types::esds::descriptor::{ + header::{DescriptorHeader, DescriptorTag}, + traits::DescriptorType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// SL Config Descriptor +/// ISO/IEC 14496-1:2010(E) - 7.2.6.8 +pub struct SLConfigDescriptor { + pub header: DescriptorHeader, + pub predefined: u8, + pub data: Bytes, +} + +impl DescriptorType for SLConfigDescriptor { + const TAG: DescriptorTag = DescriptorTag::SLConfigDescrTag; + + fn demux(header: DescriptorHeader, data: Bytes) -> io::Result { + if data.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SLConfigDescriptor must have at least 1 byte", + )); + } + + let predefined = data[0]; + let data = data.slice(1..); + + Ok(Self { + header, + predefined, + data, + }) + } + + fn primitive_size(&self) -> u64 { + 1 // predefined + + self.data.len() as u64 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u8(self.predefined)?; + writer.write_all(&self.data)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/esds/mod.rs b/video/container/mp4/src/boxes/types/esds/mod.rs new file mode 100644 index 00000000..e7176093 --- /dev/null +++ b/video/container/mp4/src/boxes/types/esds/mod.rs @@ -0,0 +1,79 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +use self::descriptor::{traits::DescriptorType, types::es::EsDescriptor, DynDescriptor}; + +pub mod descriptor; + +#[derive(Debug, Clone, PartialEq)] +/// Elementary Stream Descriptor Box +/// ISO/IEC 14496-14:2020(E) - 6.7.2 +pub struct Esds { + pub header: FullBoxHeader, + pub es_descriptor: EsDescriptor, + pub unknown: Vec, +} + +impl Esds { + pub fn new(es_descriptor: EsDescriptor) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + es_descriptor, + unknown: Vec::new(), + } + } +} + +impl BoxType for Esds { + const NAME: [u8; 4] = *b"esds"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let mut es_descriptor = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let descriptor = DynDescriptor::demux(&mut reader)?; + match descriptor { + DynDescriptor::Es(desc) => { + es_descriptor = Some(desc); + } + _ => unknown.push(descriptor), + } + } + + let es_descriptor = es_descriptor.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "esds box must contain es descriptor", + ) + })?; + + Ok(Self { + header, + es_descriptor, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + self.es_descriptor.size() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + self.es_descriptor.mux(writer)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/ftyp.rs b/video/container/mp4/src/boxes/types/ftyp.rs new file mode 100644 index 00000000..18606079 --- /dev/null +++ b/video/container/mp4/src/boxes/types/ftyp.rs @@ -0,0 +1,113 @@ +use std::io; + +use byteorder::{ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// File Type Box +/// ISO/IEC 14496-12:2022(E) - 4.2.3 +pub struct Ftyp { + pub header: BoxHeader, + pub major_brand: FourCC, + pub minor_version: u32, + pub compatible_brands: Vec, +} + +impl Ftyp { + pub fn new(major_brand: FourCC, minor_version: u32, compatible_brands: Vec) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + major_brand, + minor_version, + compatible_brands, + } + } +} + +impl BoxType for Ftyp { + const NAME: [u8; 4] = *b"ftyp"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let major_brand = FourCC::from( + TryInto::<[u8; 4]>::try_into(data.slice(0..4).as_ref()).expect("slice is 4 bytes long"), + ); + let minor_version = data + .slice(4..8) + .as_ref() + .read_u32::()?; + let compatible_brands = { + let mut compatible_brands = Vec::new(); + let mut data = data.slice(8..); + while data.len() >= 4 { + compatible_brands.push(FourCC::from( + TryInto::<[u8; 4]>::try_into(data.slice(0..4).as_ref()) + .expect("slice is 4 bytes long"), + )); + data = data.slice(4..); + } + compatible_brands + }; + + Ok(Self { + header, + major_brand, + minor_version, + compatible_brands, + }) + } + + fn primitive_size(&self) -> u64 { + 4 + 4 + (self.compatible_brands.len() * 4) as u64 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_all(&self.major_brand.to_bytes())?; + writer.write_u32::(self.minor_version)?; + for compatible_brand in &self.compatible_brands { + writer.write_all(&compatible_brand.to_bytes())?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// FourCC (Four Character Code) +pub enum FourCC { + Iso5, + Iso6, + Mp41, + Avc1, + Av01, + Hev1, + Unknown([u8; 4]), +} + +impl FourCC { + pub fn to_bytes(&self) -> [u8; 4] { + match self { + Self::Iso5 => *b"iso5", + Self::Iso6 => *b"iso6", + Self::Mp41 => *b"mp41", + Self::Avc1 => *b"avc1", + Self::Av01 => *b"av01", + Self::Hev1 => *b"hev1", + Self::Unknown(bytes) => *bytes, + } + } +} + +impl From<[u8; 4]> for FourCC { + fn from(bytes: [u8; 4]) -> Self { + match &bytes { + b"iso5" => Self::Iso5, + b"iso6" => Self::Iso6, + b"mp41" => Self::Mp41, + b"avc1" => Self::Avc1, + b"av01" => Self::Av01, + b"hev1" => Self::Hev1, + _ => Self::Unknown(bytes), + } + } +} diff --git a/video/container/mp4/src/boxes/types/hdlr.rs b/video/container/mp4/src/boxes/types/hdlr.rs new file mode 100644 index 00000000..f94f94bd --- /dev/null +++ b/video/container/mp4/src/boxes/types/hdlr.rs @@ -0,0 +1,162 @@ +use std::io::{self, Read}; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Handler Reference Box +/// ISO/IEC 14496-12:2022(E) - 8.4.3 +pub struct Hdlr { + pub header: FullBoxHeader, + pub pre_defined: u32, + pub handler_type: HandlerType, + pub reserved: [u32; 3], + pub name: String, +} + +impl Hdlr { + pub fn new(handler_type: HandlerType, name: String) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + pre_defined: 0, + handler_type, + reserved: [0; 3], + name, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// The handler type indicates the media type of the media in the track. +/// The handler type is a 32-bit value composed of a 4-character code. +pub enum HandlerType { + Vide, + Soun, + Hint, + Meta, + Unknown([u8; 4]), +} + +impl HandlerType { + pub fn to_bytes(&self) -> [u8; 4] { + match self { + Self::Vide => *b"vide", + Self::Soun => *b"soun", + Self::Hint => *b"hint", + Self::Meta => *b"meta", + Self::Unknown(b) => *b, + } + } +} + +impl From<[u8; 4]> for HandlerType { + fn from(v: [u8; 4]) -> Self { + match &v { + b"vide" => Self::Vide, + b"soun" => Self::Soun, + b"hint" => Self::Hint, + b"meta" => Self::Meta, + _ => Self::Unknown(v), + } + } +} + +impl BoxType for Hdlr { + const NAME: [u8; 4] = *b"hdlr"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let pre_defined = reader.read_u32::()?; + + let mut handler_type = [0; 4]; + reader.read_exact(&mut handler_type)?; + + let mut reserved = [0; 3]; + for v in reserved.iter_mut() { + *v = reader.read_u32::()?; + } + + let mut name = String::new(); + loop { + let c = reader.read_u8()?; + if c == 0 { + break; + } + + name.push(c as char); + } + + Ok(Self { + header, + pre_defined, + handler_type: handler_type.into(), + reserved, + name, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // pre_defined + + 4 // handler_type + + 3 * 4 // reserved + + self.name.len() as u64 + 1 // name + null terminator + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.pre_defined)?; + + writer.write_all(&self.handler_type.to_bytes())?; + + for v in self.reserved.iter() { + writer.write_u32::(*v)?; + } + + writer.write_all(self.name.as_bytes())?; + writer.write_u8(0)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hdlr version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hdlr flags must be 0", + )); + } + + if self.reserved != [0; 3] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hdlr reserved must be 0", + )); + } + + if self.pre_defined != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hdlr pre_defined must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/hev1.rs b/video/container/mp4/src/boxes/types/hev1.rs new file mode 100644 index 00000000..87a5d877 --- /dev/null +++ b/video/container/mp4/src/boxes/types/hev1.rs @@ -0,0 +1,121 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::{ + boxes::{header::BoxHeader, traits::BoxType, DynBox}, + codec::VideoCodec, +}; + +use super::{ + btrt::Btrt, + hvcc::HvcC, + stsd::{SampleEntry, VisualSampleEntry}, +}; + +#[derive(Debug, Clone, PartialEq)] +/// HEVC (H.265) Codec Box +/// ISO/IEC 14496-15:2022 - 8.4 +pub struct Hev1 { + pub header: BoxHeader, + pub visual_sample_entry: SampleEntry, + pub hvcc: HvcC, + pub btrt: Option, + pub unknown: Vec, +} + +impl Hev1 { + pub fn new( + visual_sample_entry: SampleEntry, + hvcc: HvcC, + btrt: Option, + ) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + visual_sample_entry, + hvcc, + btrt, + unknown: Vec::new(), + } + } + + pub fn codec(&self) -> io::Result { + Ok(VideoCodec::Hevc { + constraint_indicator: self.hvcc.hevc_config.general_constraint_indicator_flags, + level: self.hvcc.hevc_config.general_level_idc, + profile: self.hvcc.hevc_config.general_profile_idc, + profile_compatibility: self.hvcc.hevc_config.general_profile_compatibility_flags, + tier: self.hvcc.hevc_config.general_tier_flag, + general_profile_space: self.hvcc.hevc_config.general_profile_space, + }) + } +} + +impl BoxType for Hev1 { + const NAME: [u8; 4] = *b"hev1"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut visual_sample_entry = SampleEntry::::demux(&mut reader)?; + + let mut hvcc = None; + let mut btrt = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + match dyn_box { + DynBox::HvcC(b) => { + hvcc = Some(b); + } + DynBox::Btrt(b) => { + btrt = Some(b); + } + DynBox::Clap(b) => { + visual_sample_entry.extension.clap = Some(b); + } + DynBox::Pasp(b) => { + visual_sample_entry.extension.pasp = Some(b); + } + DynBox::Colr(b) => { + visual_sample_entry.extension.colr = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let hvcc = hvcc.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trak box is missing tkhd box") + })?; + + Ok(Self { + header, + visual_sample_entry, + hvcc, + btrt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.visual_sample_entry.size() + + self.hvcc.size() + + self.btrt.as_ref().map(|b| b.size()).unwrap_or(0) + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.visual_sample_entry.mux(writer)?; + self.hvcc.mux(writer)?; + if let Some(btrt) = &self.btrt { + btrt.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/hmhd.rs b/video/container/mp4/src/boxes/types/hmhd.rs new file mode 100644 index 00000000..cfbc7f04 --- /dev/null +++ b/video/container/mp4/src/boxes/types/hmhd.rs @@ -0,0 +1,92 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Hint Media Header Box +/// ISO/IEC 14496-12:2022(E) - 12.4.3 +pub struct Hmhd { + pub header: FullBoxHeader, + pub max_pdu_size: u16, + pub avg_pdu_size: u16, + pub max_bitrate: u32, + pub avg_bitrate: u32, + pub reserved: u32, +} + +impl BoxType for Hmhd { + const NAME: [u8; 4] = *b"hmhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let max_pdu_size = reader.read_u16::()?; + let avg_pdu_size = reader.read_u16::()?; + let max_bitrate = reader.read_u32::()?; + let avg_bitrate = reader.read_u32::()?; + let reserved = reader.read_u32::()?; + + Ok(Self { + header, + max_pdu_size, + avg_pdu_size, + max_bitrate, + avg_bitrate, + reserved, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 2 // max_pdu_size + + 2 // avg_pdu_size + + 4 // max_bitrate + + 4 // avg_bitrate + + 4 // reserved + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u16::(self.max_pdu_size)?; + writer.write_u16::(self.avg_pdu_size)?; + writer.write_u32::(self.max_bitrate)?; + writer.write_u32::(self.avg_bitrate)?; + writer.write_u32::(self.reserved)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hmhd version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hmhd flags must be 0", + )); + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "hmhd reserved must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/hvcc.rs b/video/container/mp4/src/boxes/types/hvcc.rs new file mode 100644 index 00000000..1236720a --- /dev/null +++ b/video/container/mp4/src/boxes/types/hvcc.rs @@ -0,0 +1,43 @@ +use std::io; + +use bytes::Bytes; +use h265::HEVCDecoderConfigurationRecord; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// HEVC (H.265) Configuration Box +/// ISO/IEC 14496-15:2022 - 8.4 +pub struct HvcC { + pub header: BoxHeader, + pub hevc_config: HEVCDecoderConfigurationRecord, +} + +impl HvcC { + pub fn new(hevc_config: HEVCDecoderConfigurationRecord) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + hevc_config, + } + } +} + +impl BoxType for HvcC { + const NAME: [u8; 4] = *b"hvcC"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + Ok(Self { + header, + hevc_config: HEVCDecoderConfigurationRecord::demux(&mut reader)?, + }) + } + + fn primitive_size(&self) -> u64 { + self.hevc_config.size() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.hevc_config.mux(writer) + } +} diff --git a/video/container/mp4/src/boxes/types/mdat.rs b/video/container/mp4/src/boxes/types/mdat.rs new file mode 100644 index 00000000..4ac2ffdb --- /dev/null +++ b/video/container/mp4/src/boxes/types/mdat.rs @@ -0,0 +1,45 @@ +use std::io; + +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// Media Data Box +/// ISO/IEC 14496-12:2022(E) - 8.2.2 +pub struct Mdat { + pub header: BoxHeader, + pub data: Vec, +} + +impl Mdat { + pub fn new(data: Vec) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + data, + } + } +} + +impl BoxType for Mdat { + const NAME: [u8; 4] = *b"mdat"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + Ok(Self { + header, + data: vec![data], + }) + } + + fn primitive_size(&self) -> u64 { + self.data.iter().map(|data| data.len() as u64).sum() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + for data in &self.data { + writer.write_all(data)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mdhd.rs b/video/container/mp4/src/boxes/types/mdhd.rs new file mode 100644 index 00000000..78c4da86 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mdhd.rs @@ -0,0 +1,164 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Media Header Box +/// ISO/IEC 14496-12:2022(E) - 8.4.2 +pub struct Mdhd { + pub header: FullBoxHeader, + pub creation_time: u64, + pub modification_time: u64, + pub timescale: u32, + pub duration: u64, + pub language: u16, + pub pre_defined: u16, +} + +impl Mdhd { + pub fn new(creation_time: u64, modification_time: u64, timescale: u32, duration: u64) -> Self { + let version = if creation_time > u32::MAX as u64 + || modification_time > u32::MAX as u64 + || duration > u32::MAX as u64 + { + 1 + } else { + 0 + }; + + Self { + header: FullBoxHeader::new(Self::NAME, version, 0), + creation_time, + modification_time, + timescale, + duration, + language: 0x55c4, // und + pre_defined: 0, + } + } +} + +impl BoxType for Mdhd { + const NAME: [u8; 4] = *b"mdhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let (creation_time, modification_time, timescale, duration) = if header.version == 1 { + ( + reader.read_u64::()?, // creation_time + reader.read_u64::()?, // modification_time + reader.read_u32::()?, // timescale + reader.read_u64::()?, // duration + ) + } else { + ( + reader.read_u32::()? as u64, // creation_time + reader.read_u32::()? as u64, // modification_time + reader.read_u32::()?, // timescale + reader.read_u32::()? as u64, // duration + ) + }; + + let language = reader.read_u16::()?; + let pre_defined = reader.read_u16::()?; + + Ok(Self { + header, + creation_time, + modification_time, + timescale, + duration, + language, + pre_defined, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + if self.header.version == 1 { + 8 + 8 + 4 + 8 // creation_time + modification_time + timescale + duration + } else { + 4 + 4 + 4 + 4 // creation_time + modification_time + timescale + duration + } + + 2 // language + + 2 // pre_defined + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if self.header.version == 1 { + writer.write_u64::(self.creation_time)?; + writer.write_u64::(self.modification_time)?; + writer.write_u32::(self.timescale)?; + writer.write_u64::(self.duration)?; + } else { + writer.write_u32::(self.creation_time as u32)?; + writer.write_u32::(self.modification_time as u32)?; + writer.write_u32::(self.timescale)?; + writer.write_u32::(self.duration as u32)?; + } + + writer.write_u16::(self.language)?; + writer.write_u16::(self.pre_defined)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box flags must be 0", + )); + } + + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box version must be 0 or 1", + )); + } + + if self.header.version == 0 { + if self.creation_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box creation_time must be less than 2^32", + )); + } + + if self.modification_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box modification_time must be less than 2^32", + )); + } + + if self.duration > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box duration must be less than 2^32", + )); + } + } + + if self.pre_defined != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mdhd box pre_defined must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mdia.rs b/video/container/mp4/src/boxes/types/mdia.rs new file mode 100644 index 00000000..41309564 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mdia.rs @@ -0,0 +1,100 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{hdlr::Hdlr, mdhd::Mdhd, minf::Minf}; + +#[derive(Debug, Clone, PartialEq)] +/// Media Box +/// ISO/IEC 14496-12:2022(E) - 8.4 +pub struct Mdia { + pub header: BoxHeader, + pub mdhd: Mdhd, + pub hdlr: Hdlr, + pub minf: Minf, + pub unknown: Vec, +} + +impl Mdia { + pub fn new(mdhd: Mdhd, hdlr: Hdlr, minf: Minf) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + mdhd, + hdlr, + minf, + unknown: Vec::new(), + } + } +} + +impl BoxType for Mdia { + const NAME: [u8; 4] = *b"mdia"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + let mut mdhd = None; + let mut hdlr = None; + let mut minf = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Mdhd(b) => { + mdhd = Some(b); + } + DynBox::Hdlr(b) => { + hdlr = Some(b); + } + DynBox::Minf(b) => { + minf = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let mdhd = mdhd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "mdia box is missing mdhd box") + })?; + + let hdlr = hdlr.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "mdia box is missing hdlr box") + })?; + + let minf = minf.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "mdia box is missing minf box") + })?; + + Ok(Self { + header, + mdhd, + hdlr, + minf, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.mdhd.size() // mdhd + + self.hdlr.size() // hdlr + + self.minf.size() // minf + + self.unknown.iter().map(|b| b.size()).sum::() // unknown boxes + } + + fn primitive_mux(&self, writer: &mut W) -> io::Result<()> { + self.mdhd.mux(writer)?; + self.hdlr.mux(writer)?; + self.minf.mux(writer)?; + + for b in &self.unknown { + b.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mehd.rs b/video/container/mp4/src/boxes/types/mehd.rs new file mode 100644 index 00000000..61ee1996 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mehd.rs @@ -0,0 +1,77 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Extends Header Box +/// ISO/IEC 14496-12:2022(E) - 8.8.2 +pub struct Mehd { + pub header: FullBoxHeader, + pub fragment_duration: u64, +} + +impl BoxType for Mehd { + const NAME: [u8; 4] = *b"mehd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let fragment_duration = if header.version == 1 { + reader.read_u64::()? + } else { + reader.read_u32::()? as u64 + }; + + Ok(Self { + header, + fragment_duration, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + if self.header.version == 1 { + 8 // fragment_duration + } else { + 4 // fragment_duration + } + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if self.header.version == 1 { + writer.write_u64::(self.fragment_duration)?; + } else { + writer.write_u32::(self.fragment_duration as u32)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mehd version must be 0 or 1", + )); + } + + if self.header.version == 0 && self.fragment_duration > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mehd fragment_duration must be less than 2^32", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mfhd.rs b/video/container/mp4/src/boxes/types/mfhd.rs new file mode 100644 index 00000000..5f9317e6 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mfhd.rs @@ -0,0 +1,72 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Fragment Header Box +/// ISO/IEC 14496-12:2022(E) - 8.8.5 +pub struct Mfhd { + pub header: FullBoxHeader, + pub sequence_number: u32, +} + +impl Mfhd { + pub fn new(sequence_number: u32) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + sequence_number, + } + } +} + +impl BoxType for Mfhd { + const NAME: [u8; 4] = *b"mfhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let sequence_number = reader.read_u32::()?; + + Ok(Self { + header, + sequence_number, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + 4 // sequence_number + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + writer.write_u32::(self.sequence_number)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mfhd box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mfhd box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/minf.rs b/video/container/mp4/src/boxes/types/minf.rs new file mode 100644 index 00000000..236651f1 --- /dev/null +++ b/video/container/mp4/src/boxes/types/minf.rs @@ -0,0 +1,135 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{dinf::Dinf, hmhd::Hmhd, nmhd::Nmhd, smhd::Smhd, stbl::Stbl, vmhd::Vmhd}; + +#[derive(Debug, Clone, PartialEq)] +/// Media Information Box +/// ISO/IEC 14496-12:2022(E) - 8.4.4 +pub struct Minf { + pub header: BoxHeader, + pub vmhd: Option, + pub smhd: Option, + pub hmhd: Option, + pub nmhd: Option, + pub dinf: Dinf, + pub stbl: Stbl, + pub unknown: Vec, +} + +impl Minf { + pub fn new(stbl: Stbl, vmhd: Option, smhd: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + vmhd, + smhd, + hmhd: None, + nmhd: None, + dinf: Dinf::new(), + stbl, + unknown: Vec::new(), + } + } +} + +impl BoxType for Minf { + const NAME: [u8; 4] = *b"minf"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + let mut vmhd = None; + let mut smhd = None; + let mut hmhd = None; + let mut nmhd = None; + let mut dinf = None; + let mut stbl = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Vmhd(b) => { + vmhd = Some(b); + } + DynBox::Smhd(b) => { + smhd = Some(b); + } + DynBox::Hmhd(b) => { + hmhd = Some(b); + } + DynBox::Nmhd(b) => { + nmhd = Some(b); + } + DynBox::Dinf(b) => { + dinf = Some(b); + } + DynBox::Stbl(b) => { + stbl = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let dinf = dinf.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "minf: dinf box is required") + })?; + let stbl = stbl.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "minf: stbl box is required") + })?; + + Ok(Self { + header, + vmhd, + smhd, + hmhd, + nmhd, + dinf, + stbl, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.vmhd.as_ref().map(|b| b.size()).unwrap_or(0) // vmhd + + self.smhd.as_ref().map(|b| b.size()).unwrap_or(0) // smhd + + self.hmhd.as_ref().map(|b| b.size()).unwrap_or(0) // hmhd + + self.nmhd.as_ref().map(|b| b.size()).unwrap_or(0) // nmhd + + self.dinf.size() // dinf + + self.stbl.size() // stbl + + self.unknown.iter().map(|b| b.size()).sum::() // unknown boxes + } + + fn primitive_mux(&self, writer: &mut W) -> io::Result<()> { + if let Some(b) = &self.vmhd { + b.mux(writer)?; + } + + if let Some(b) = &self.smhd { + b.mux(writer)?; + } + + if let Some(b) = &self.hmhd { + b.mux(writer)?; + } + + if let Some(b) = &self.nmhd { + b.mux(writer)?; + } + + self.dinf.mux(writer)?; + + self.stbl.mux(writer)?; + + for b in &self.unknown { + b.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mod.rs b/video/container/mp4/src/boxes/types/mod.rs new file mode 100644 index 00000000..267fc68a --- /dev/null +++ b/video/container/mp4/src/boxes/types/mod.rs @@ -0,0 +1,57 @@ +pub mod av01; +pub mod av1c; +pub mod avc1; +pub mod avcc; +pub mod btrt; +pub mod clap; +pub mod co64; +pub mod colr; +pub mod ctts; +pub mod dinf; +pub mod dref; +pub mod edts; +pub mod elst; +pub mod esds; +pub mod ftyp; +pub mod hdlr; +pub mod hev1; +pub mod hmhd; +pub mod hvcc; +pub mod mdat; +pub mod mdhd; +pub mod mdia; +pub mod mehd; +pub mod mfhd; +pub mod minf; +pub mod moof; +pub mod moov; +pub mod mp4a; +pub mod mvex; +pub mod mvhd; +pub mod nmhd; +pub mod opus; +pub mod padb; +pub mod pasp; +pub mod sbgp; +pub mod sdtp; +pub mod smhd; +pub mod stbl; +pub mod stco; +pub mod stdp; +pub mod stsc; +pub mod stsd; +pub mod stsh; +pub mod stss; +pub mod stsz; +pub mod stts; +pub mod stz2; +pub mod subs; +pub mod tfdt; +pub mod tfhd; +pub mod tkhd; +pub mod traf; +pub mod trak; +pub mod trex; +pub mod trun; +pub mod url; +pub mod vmhd; diff --git a/video/container/mp4/src/boxes/types/moof.rs b/video/container/mp4/src/boxes/types/moof.rs new file mode 100644 index 00000000..4bbfb104 --- /dev/null +++ b/video/container/mp4/src/boxes/types/moof.rs @@ -0,0 +1,85 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{mfhd::Mfhd, traf::Traf}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Fragment Box +/// ISO/IEC 14496-12:2022(E) - 8.8.4 +pub struct Moof { + pub header: BoxHeader, + pub mfhd: Mfhd, + pub traf: Vec, + pub unknown: Vec, +} + +impl Moof { + pub fn new(mfhd: Mfhd, traf: Vec) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + mfhd, + traf, + unknown: Vec::new(), + } + } +} + +impl BoxType for Moof { + const NAME: [u8; 4] = *b"moof"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut unknown = Vec::new(); + + let mut traf = Vec::new(); + let mut mfhd = None; + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader)?; + match box_ { + DynBox::Mfhd(b) => { + mfhd = Some(b); + } + DynBox::Traf(b) => { + traf.push(b); + } + _ => unknown.push(box_), + } + } + + let mfhd = mfhd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "moof box must contain mfhd box") + })?; + + Ok(Self { + header, + mfhd, + traf, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.mfhd.size() + + self.traf.iter().map(|box_| box_.size()).sum::() + + self.unknown.iter().map(|box_| box_.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.mfhd.mux(writer)?; + + for box_ in &self.traf { + box_.mux(writer)?; + } + + for box_ in &self.unknown { + box_.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/moov.rs b/video/container/mp4/src/boxes/types/moov.rs new file mode 100644 index 00000000..99eac29a --- /dev/null +++ b/video/container/mp4/src/boxes/types/moov.rs @@ -0,0 +1,99 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{mvex::Mvex, mvhd::Mvhd, trak::Trak}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Box +/// ISO/IEC 14496-12:2022(E) - 8.2.1 +pub struct Moov { + pub header: BoxHeader, + pub mvhd: Mvhd, + pub traks: Vec, + pub mvex: Option, + pub unknown: Vec, +} + +impl Moov { + pub fn new(mvhd: Mvhd, traks: Vec, mvex: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + mvhd, + traks, + mvex, + unknown: Vec::new(), + } + } +} + +impl BoxType for Moov { + const NAME: [u8; 4] = *b"moov"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut traks = Vec::new(); + let mut mvex = None; + let mut mvhd = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Mvhd(b) => { + mvhd = Some(b); + } + DynBox::Trak(b) => { + traks.push(b); + } + DynBox::Mvex(b) => { + mvex = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let mvhd = mvhd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "moov box is missing mvhd box") + })?; + + Ok(Self { + header, + mvhd, + traks, + mvex, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.mvhd.size() + + self.traks.iter().map(|b| b.size()).sum::() + + self.mvex.as_ref().map(|b| b.size()).unwrap_or(0) + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.mvhd.mux(writer)?; + + for trak in &self.traks { + trak.mux(writer)?; + } + + if let Some(mvex) = &self.mvex { + mvex.mux(writer)?; + } + + for unknown in &self.unknown { + unknown.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mp4a.rs b/video/container/mp4/src/boxes/types/mp4a.rs new file mode 100644 index 00000000..bbbfd52f --- /dev/null +++ b/video/container/mp4/src/boxes/types/mp4a.rs @@ -0,0 +1,116 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::{ + boxes::{header::BoxHeader, traits::BoxType, DynBox}, + codec::AudioCodec, +}; + +use super::{ + btrt::Btrt, + esds::Esds, + stsd::{AudioSampleEntry, SampleEntry}, +}; + +#[derive(Debug, Clone, PartialEq)] +/// AAC Audio Sample Entry +/// ISO/IEC 14496-14:2020(E) - 6.7 +pub struct Mp4a { + pub header: BoxHeader, + pub audio_sample_entry: SampleEntry, + pub esds: Esds, + pub btrt: Option, + pub unknown: Vec, +} + +impl Mp4a { + pub fn new( + audio_sample_entry: SampleEntry, + esds: Esds, + btrt: Option, + ) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + audio_sample_entry, + esds, + btrt, + unknown: Vec::new(), + } + } + + pub fn codec(&self) -> io::Result { + let info = self + .esds + .es_descriptor + .decoder_config + .as_ref() + .and_then(|c| c.decoder_specific_info.as_ref().map(|c| c.data.clone())) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Missing decoder specific info") + })?; + let aac_config = aac::AudioSpecificConfig::parse(info)?; + + Ok(AudioCodec::Aac { + object_type: aac_config.audio_object_type, + }) + } +} + +impl BoxType for Mp4a { + const NAME: [u8; 4] = *b"mp4a"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let audio_sample_entry = SampleEntry::::demux(&mut reader)?; + let mut btrt = None; + let mut esds = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + match dyn_box { + DynBox::Btrt(btrt_box) => { + btrt = Some(btrt_box); + } + DynBox::Esds(esds_box) => { + esds = Some(esds_box); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let esds = + esds.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Missing esds box"))?; + + Ok(Self { + header, + audio_sample_entry, + esds, + btrt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.audio_sample_entry.size() + + self.btrt.as_ref().map(|b| b.size()).unwrap_or(0) + + self.esds.size() + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.audio_sample_entry.mux(writer)?; + self.esds.mux(writer)?; + if let Some(btrt) = &self.btrt { + btrt.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mvex.rs b/video/container/mp4/src/boxes/types/mvex.rs new file mode 100644 index 00000000..aac2a2d1 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mvex.rs @@ -0,0 +1,84 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{mehd::Mehd, trex::Trex}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Extends Box +/// ISO/IEC 14496-12:2022(E) - 8.8.1 +pub struct Mvex { + pub header: BoxHeader, + pub trex: Vec, + pub mehd: Option, + pub unknown: Vec, +} + +impl Mvex { + pub fn new(trex: Vec, mehd: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + trex, + mehd, + unknown: Vec::new(), + } + } +} + +impl BoxType for Mvex { + const NAME: [u8; 4] = *b"mvex"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut trex = Vec::new(); + let mut mehd = None; + let mut unknown = Vec::new(); + + let mut data = io::Cursor::new(data); + while data.has_remaining() { + let dyn_box = DynBox::demux(&mut data)?; + + match dyn_box { + DynBox::Trex(b) => { + trex.push(b); + } + DynBox::Mehd(b) => { + mehd = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + Ok(Self { + header, + trex, + mehd, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.trex.iter().map(|b| b.size()).sum::() + + self.mehd.iter().map(|b| b.size()).sum::() + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + for b in &self.trex { + b.mux(writer)?; + } + + if let Some(b) = &self.mehd { + b.mux(writer)?; + } + + for b in &self.unknown { + b.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/mvhd.rs b/video/container/mp4/src/boxes/types/mvhd.rs new file mode 100644 index 00000000..5a1e5786 --- /dev/null +++ b/video/container/mp4/src/boxes/types/mvhd.rs @@ -0,0 +1,239 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use fixed::{ + types::extra::{U16, U8}, + FixedI16, FixedI32, +}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Movie Header Box +/// ISO/IEC 14496-12:2022(E) - 8.2.2 +pub struct Mvhd { + pub header: FullBoxHeader, + pub creation_time: u64, + pub modification_time: u64, + pub timescale: u32, + pub duration: u64, + pub rate: FixedI32, + pub volume: FixedI16, + pub reserved: u16, + pub reserved2: [u32; 2], + pub matrix: [u32; 9], + pub pre_defined: [u32; 6], + pub next_track_id: u32, +} + +impl Mvhd { + pub fn new( + creation_time: u64, + modification_time: u64, + timescale: u32, + duration: u64, + next_track_id: u32, + ) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + creation_time, + modification_time, + timescale, + duration, + rate: FixedI32::::from_num(1), + volume: FixedI16::::from_num(1), + reserved: 0, + reserved2: [0; 2], + matrix: Self::MATRIX, + pre_defined: [0; 6], + next_track_id, + } + } +} + +impl Mvhd { + pub const MATRIX: [u32; 9] = [0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000]; +} + +impl BoxType for Mvhd { + const NAME: [u8; 4] = *b"mvhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let (creation_time, modification_time, timescale, duration) = if header.version == 1 { + ( + reader.read_u64::()?, // creation_time + reader.read_u64::()?, // modification_time + reader.read_u32::()?, // timescale + reader.read_u64::()?, // duration + ) + } else { + ( + reader.read_u32::()? as u64, // creation_time + reader.read_u32::()? as u64, // modification_time + reader.read_u32::()?, // timescale + reader.read_u32::()? as u64, // duration + ) + }; + + let rate = reader.read_i32::()?; + let volume = reader.read_i16::()?; + + let reserved = reader.read_u16::()?; + let mut reserved2 = [0; 2]; + for v in reserved2.iter_mut() { + *v = reader.read_u32::()?; + } + + let mut matrix = [0; 9]; + for v in matrix.iter_mut() { + *v = reader.read_u32::()?; + } + + let mut pre_defined = [0; 6]; + for v in pre_defined.iter_mut() { + *v = reader.read_u32::()?; + } + + let next_track_id = reader.read_u32::()?; + + Ok(Self { + header, + creation_time, + modification_time, + timescale, + duration, + rate: FixedI32::from_bits(rate), + volume: FixedI16::from_bits(volume), + reserved, + reserved2, + matrix, + pre_defined, + next_track_id, + }) + } + + fn primitive_size(&self) -> u64 { + let mut size = self.header.size(); + + if self.header.version == 1 { + size += 8 + 8 + 4 + 8; // creation_time, modification_time, timescale, duration + } else { + size += 4 + 4 + 4 + 4; // creation_time, modification_time, timescale, duration + } + + size += 4 + 2 + 2; // rate, volume, reserved + size += 4 * 2; // reserved2 + size += 4 * 9; // matrix + size += 4 * 6; // pre_defined + size += 4; // next_track_id + + size + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if self.header.version == 1 { + writer.write_u64::(self.creation_time)?; + writer.write_u64::(self.modification_time)?; + writer.write_u32::(self.timescale)?; + writer.write_u64::(self.duration)?; + } else { + writer.write_u32::(self.creation_time as u32)?; + writer.write_u32::(self.modification_time as u32)?; + writer.write_u32::(self.timescale)?; + writer.write_u32::(self.duration as u32)?; + } + + writer.write_i32::(self.rate.to_bits())?; + writer.write_i16::(self.volume.to_bits())?; + + writer.write_u16::(self.reserved)?; + + for v in self.reserved2.iter() { + writer.write_u32::(*v)?; + } + + for v in self.matrix.iter() { + writer.write_u32::(*v)?; + } + + for v in self.pre_defined.iter() { + writer.write_u32::(*v)?; + } + + writer.write_u32::(self.next_track_id)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd flags must be 0", + )); + } + + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd version must be 0 or 1", + )); + } + + if self.header.version == 0 { + if self.creation_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd creation_time must be less than 2^32", + )); + } + + if self.modification_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd modification_time must be less than 2^32", + )); + } + + if self.duration > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd duration must be less than 2^32", + )); + } + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd reserved must be 0", + )); + } + + if self.reserved2 != [0; 2] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd reserved2 must be 0", + )); + } + + if self.pre_defined != [0; 6] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mvhd pre_defined must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/nmhd.rs b/video/container/mp4/src/boxes/types/nmhd.rs new file mode 100644 index 00000000..088e522c --- /dev/null +++ b/video/container/mp4/src/boxes/types/nmhd.rs @@ -0,0 +1,48 @@ +use std::io; + +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Null media header box +/// ISO/IEC 14496-12:2022(E) 8.4.5.2 +pub struct Nmhd { + pub header: FullBoxHeader, +} + +impl BoxType for Nmhd { + const NAME: [u8; 4] = *b"nmhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + Ok(Self { header }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "nmhd box version must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/opus.rs b/video/container/mp4/src/boxes/types/opus.rs new file mode 100644 index 00000000..132b7efe --- /dev/null +++ b/video/container/mp4/src/boxes/types/opus.rs @@ -0,0 +1,86 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::{ + boxes::{header::BoxHeader, traits::BoxType, DynBox}, + codec::AudioCodec, +}; + +use super::{ + btrt::Btrt, + stsd::{AudioSampleEntry, SampleEntry}, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Opus Audio Sample Entry +/// Encapsulation of Opus in ISO Base Media File Format - Version 0.8.1 +pub struct Opus { + pub header: BoxHeader, + pub audio_sample_entry: SampleEntry, + pub btrt: Option, + pub unknown: Vec, +} + +impl Opus { + pub fn new(audio_sample_entry: SampleEntry, btrt: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + audio_sample_entry, + btrt, + unknown: Vec::new(), + } + } + + pub fn codec(&self) -> io::Result { + Ok(AudioCodec::Opus) + } +} + +impl BoxType for Opus { + const NAME: [u8; 4] = *b"Opus"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let audio_sample_entry = SampleEntry::::demux(&mut reader)?; + let mut btrt = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + match dyn_box { + DynBox::Btrt(btrt_box) => { + btrt = Some(btrt_box); + } + _ => { + unknown.push(dyn_box); + } + } + } + + Ok(Self { + header, + audio_sample_entry, + btrt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.audio_sample_entry.size() + + self.btrt.as_ref().map(|b| b.size()).unwrap_or(0) + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.audio_sample_entry.mux(writer)?; + if let Some(btrt) = &self.btrt { + btrt.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/padb.rs b/video/container/mp4/src/boxes/types/padb.rs new file mode 100644 index 00000000..a69a9541 --- /dev/null +++ b/video/container/mp4/src/boxes/types/padb.rs @@ -0,0 +1,73 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Padding bits box. +/// ISO/IEC 14496-12:2022(E) - 8.7.6 +pub struct Padb { + pub header: FullBoxHeader, + pub samples: Vec, +} + +impl BoxType for Padb { + const NAME: [u8; 4] = *b"padb"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let sample_count = (reader.read_u32::()? + 1) / 2; + let mut samples = Vec::with_capacity(sample_count as usize); + + for _ in 0..sample_count { + let byte = reader.read_u8()?; + samples.push(byte); + } + + Ok(Self { header, samples }) + } + + fn primitive_size(&self) -> u64 { + let mut size = self.header.size(); + size += 4; // sample_count + size + (self.samples.len() as u64) // samples + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::((self.samples.len() as u32) * 2 - 1)?; + + for byte in &self.samples { + writer.write_u8(*byte)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "padb box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "padb box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/pasp.rs b/video/container/mp4/src/boxes/types/pasp.rs new file mode 100644 index 00000000..c1ac3747 --- /dev/null +++ b/video/container/mp4/src/boxes/types/pasp.rs @@ -0,0 +1,61 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{header::BoxHeader, traits::BoxType}; + +#[derive(Debug, Clone, PartialEq)] +/// Pixel Aspect Ratio Box +/// ISO/IEC 14496-12:2022(E) - 12.1.4.2 +pub struct Pasp { + pub header: BoxHeader, + pub h_spacing: u32, + pub v_spacing: u32, +} + +impl Default for Pasp { + fn default() -> Self { + Self::new() + } +} + +impl Pasp { + pub fn new() -> Self { + Self { + header: BoxHeader::new(Self::NAME), + h_spacing: 1, + v_spacing: 1, + } + } +} + +impl BoxType for Pasp { + const NAME: [u8; 4] = *b"pasp"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let h_spacing = reader.read_u32::()?; + let v_spacing = reader.read_u32::()?; + + Ok(Self { + header, + + h_spacing, + v_spacing, + }) + } + + fn primitive_size(&self) -> u64 { + 4 // h_spacing + + 4 // v_spacing + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u32::(self.h_spacing)?; + writer.write_u32::(self.v_spacing)?; + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/sbgp.rs b/video/container/mp4/src/boxes/types/sbgp.rs new file mode 100644 index 00000000..7f22e046 --- /dev/null +++ b/video/container/mp4/src/boxes/types/sbgp.rs @@ -0,0 +1,113 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample to Group Box +/// ISO/IEC 14496-12:2022(E) - 8.9.2 +pub struct Sbgp { + pub header: FullBoxHeader, + pub grouping_type: Option, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SbgpEntry { + pub sample_count: u32, + pub group_description_index: u32, +} + +impl BoxType for Sbgp { + const NAME: [u8; 4] = *b"sbgp"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut data = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut data)?; + + let grouping_type = if header.version == 1 { + Some(data.read_u32::()?) + } else { + None + }; + let entry_count = data.read_u32::()?; + + let mut entries = Vec::with_capacity(entry_count as usize); + + for _ in 0..entry_count { + let sample_count = data.read_u32::()?; + let group_description_index = data.read_u32::()?; + + entries.push(SbgpEntry { + sample_count, + group_description_index, + }); + } + + Ok(Self { + header, + grouping_type, + entries, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // grouping_type + + 4 // entry_count + + (self.entries.len() as u64 * 8) // entries + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if let Some(grouping_type) = self.grouping_type { + writer.write_u32::(grouping_type)?; + } + + writer.write_u32::(self.entries.len() as u32)?; + + for entry in &self.entries { + writer.write_u32::(entry.sample_count)?; + writer.write_u32::(entry.group_description_index)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sbgp box version must be 0 or 1", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sbgp box flags must be 0", + )); + } + + if self.header.version == 1 && self.grouping_type.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sbgp box grouping_type must be present when version is 1", + )); + } else if self.header.version == 0 && self.grouping_type.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sbgp box grouping_type must not be present when version is 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/sdtp.rs b/video/container/mp4/src/boxes/types/sdtp.rs new file mode 100644 index 00000000..ee6af8bd --- /dev/null +++ b/video/container/mp4/src/boxes/types/sdtp.rs @@ -0,0 +1,87 @@ +use std::io; + +use byteorder::WriteBytesExt; +use bytes::{Buf, Bytes}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Dependency Type Box +/// ISO/IEC 14496-12:2022(E) 8.6.4 +pub struct Sdtp { + pub header: FullBoxHeader, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Sample Dependency Type Entry +pub struct SdtpEntry { + pub sample_is_leading: u8, // 2 bits + pub sample_depends_on: u8, // 2 bits + pub sample_is_depended_on: u8, // 2 bits + pub sample_has_redundancy: u8, // 2 bits +} + +impl BoxType for Sdtp { + const NAME: [u8; 4] = *b"sdtp"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let mut entries = Vec::new(); + while reader.has_remaining() { + let byte = reader.get_u8(); + entries.push(SdtpEntry { + sample_is_leading: (byte & 0b11000000) >> 6, + sample_depends_on: (byte & 0b00110000) >> 4, + sample_is_depended_on: (byte & 0b00001100) >> 2, + sample_has_redundancy: byte & 0b00000011, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + // entries + size + (self.entries.len() as u64) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + for entry in &self.entries { + let byte = (entry.sample_is_leading << 6) + | (entry.sample_depends_on << 4) + | (entry.sample_is_depended_on << 2) + | entry.sample_has_redundancy; + writer.write_u8(byte)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sdtp box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sdtp box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/smhd.rs b/video/container/mp4/src/boxes/types/smhd.rs new file mode 100644 index 00000000..594caa3f --- /dev/null +++ b/video/container/mp4/src/boxes/types/smhd.rs @@ -0,0 +1,95 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use fixed::{types::extra::U8, FixedI16}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sound Media Header Box +/// ISO/IEC 14496-12:2022(E) - 12.2.2 +pub struct Smhd { + pub header: FullBoxHeader, + pub balance: FixedI16, + pub reserved: u16, +} + +impl Default for Smhd { + fn default() -> Self { + Self::new() + } +} + +impl Smhd { + pub fn new() -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + balance: FixedI16::::from_num(0), + reserved: 0, + } + } +} + +impl BoxType for Smhd { + const NAME: [u8; 4] = *b"smhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let balance = reader.read_i16::()?; + let reserved = reader.read_u16::()?; + + Ok(Self { + header, + balance: FixedI16::from_bits(balance), + reserved, + }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 2; // balance + // reserved + size + 2 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_i16::(self.balance.to_bits())?; + writer.write_u16::(self.reserved)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "smhd version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "smhd flags must be 0", + )); + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "smhd reserved must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stbl.rs b/video/container/mp4/src/boxes/types/stbl.rs new file mode 100644 index 00000000..883db541 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stbl.rs @@ -0,0 +1,233 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use super::{ + co64::Co64, ctts::Ctts, padb::Padb, sbgp::Sbgp, sdtp::Sdtp, stco::Stco, stdp::Stdp, stsc::Stsc, + stsd::Stsd, stsh::Stsh, stss::Stss, stsz::Stsz, stts::Stts, stz2::Stz2, subs::Subs, +}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Table Box +/// ISO/IEC 14496-12:2022(E) 8.5.1 +pub struct Stbl { + pub header: BoxHeader, + pub stsd: Stsd, + pub stts: Stts, + pub ctts: Option, + pub stsc: Stsc, + pub stsz: Option, + pub stz2: Option, + pub stco: Stco, + pub co64: Option, + pub stss: Option, + pub stsh: Option, + pub padb: Option, + pub stdp: Option, + pub sdtp: Option, + pub sbgp: Option, + pub subs: Option, + pub unknown: Vec, +} + +impl Stbl { + pub fn new(stsd: Stsd, stts: Stts, stsc: Stsc, stco: Stco, stsz: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + stsd, + stts, + ctts: None, + stsc, + stsz, + stz2: None, + stco, + co64: None, + stss: None, + stsh: None, + padb: None, + stdp: None, + sdtp: None, + sbgp: None, + subs: None, + unknown: Vec::new(), + } + } +} + +impl BoxType for Stbl { + const NAME: [u8; 4] = *b"stbl"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + let mut stsd = None; + let mut stts = None; + let mut ctts = None; + let mut stsc = None; + let mut stsz = None; + let mut stz2 = None; + let mut stco = None; + let mut co64 = None; + let mut stss = None; + let mut stsh = None; + let mut padb = None; + let mut stdp = None; + let mut sdtp = None; + let mut sbgp = None; + let mut subs = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Stsd(b) => { + stsd = Some(b); + } + DynBox::Stts(b) => { + stts = Some(b); + } + DynBox::Ctts(b) => { + ctts = Some(b); + } + DynBox::Stsc(b) => { + stsc = Some(b); + } + DynBox::Stsz(b) => { + stsz = Some(b); + } + DynBox::Stz2(b) => { + stz2 = Some(b); + } + DynBox::Stco(b) => { + stco = Some(b); + } + DynBox::Co64(b) => { + co64 = Some(b); + } + DynBox::Stss(b) => { + stss = Some(b); + } + DynBox::Stsh(b) => { + stsh = Some(b); + } + DynBox::Padb(b) => { + padb = Some(b); + } + DynBox::Stdp(b) => { + stdp = Some(b); + } + DynBox::Sdtp(b) => { + sdtp = Some(b); + } + DynBox::Sbgp(b) => { + sbgp = Some(b); + } + DynBox::Subs(b) => { + subs = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let stsd = stsd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "stsd box not found in stbl box") + })?; + let stts = stts.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "stts box not found in stbl box") + })?; + let stsc = stsc.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "stsc box not found in stbl box") + })?; + let stco = stco.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "stco box not found in stbl box") + })?; + + Ok(Self { + header, + stsd, + stts, + ctts, + stsc, + stsz, + stz2, + stco, + co64, + stss, + stsh, + padb, + stdp, + sdtp, + sbgp, + subs, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + let mut size = self.stsd.size(); + size += self.stts.size(); + size += self.ctts.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stsc.size(); + size += self.stsz.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stz2.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stco.size(); + size += self.co64.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stss.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stsh.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.padb.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.stdp.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.sdtp.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.sbgp.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.subs.as_ref().map(|b| b.size()).unwrap_or(0); + size += self.unknown.iter().map(|b| b.size()).sum::(); + size + } + + fn primitive_mux(&self, writer: &mut W) -> io::Result<()> { + self.stsd.mux(writer)?; + self.stts.mux(writer)?; + if let Some(ctts) = &self.ctts { + ctts.mux(writer)?; + } + self.stsc.mux(writer)?; + if let Some(stsz) = &self.stsz { + stsz.mux(writer)?; + } + if let Some(stz2) = &self.stz2 { + stz2.mux(writer)?; + } + self.stco.mux(writer)?; + if let Some(co64) = &self.co64 { + co64.mux(writer)?; + } + if let Some(stss) = &self.stss { + stss.mux(writer)?; + } + if let Some(stsh) = &self.stsh { + stsh.mux(writer)?; + } + if let Some(padb) = &self.padb { + padb.mux(writer)?; + } + if let Some(stdp) = &self.stdp { + stdp.mux(writer)?; + } + if let Some(sdtp) = &self.sdtp { + sdtp.mux(writer)?; + } + if let Some(sbgp) = &self.sbgp { + sbgp.mux(writer)?; + } + if let Some(subs) = &self.subs { + subs.mux(writer)?; + } + for unknown in &self.unknown { + unknown.mux(writer)?; + } + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stco.rs b/video/container/mp4/src/boxes/types/stco.rs new file mode 100644 index 00000000..fbcd195f --- /dev/null +++ b/video/container/mp4/src/boxes/types/stco.rs @@ -0,0 +1,81 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Table Chunk Offset Box +/// ISO/IEC 14496-12:2022(E) - 8.7.5 +pub struct Stco { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Stco { + pub fn new(entries: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries, + } + } +} + +impl BoxType for Stco { + const NAME: [u8; 4] = *b"stco"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let offset = reader.read_u32::()?; + entries.push(offset); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + // entries + size + (self.entries.len() as u64 * 4) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for offset in &self.entries { + writer.write_u32::(*offset)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stco box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stco box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stdp.rs b/video/container/mp4/src/boxes/types/stdp.rs new file mode 100644 index 00000000..41824238 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stdp.rs @@ -0,0 +1,70 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{Buf, Bytes}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Degradation Priority Box +/// ISO/IEC 14496-12:2022(E) - 8.7.6 +pub struct Stdp { + pub header: FullBoxHeader, + pub samples: Vec, +} + +impl BoxType for Stdp { + const NAME: [u8; 4] = *b"stdp"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let mut samples = Vec::new(); + + while reader.remaining() > 1 { + let sample = reader.read_u16::()?; + samples.push(sample); + } + + Ok(Self { header, samples }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + // samples + size + (self.samples.len() as u64) * 2 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + for sample in &self.samples { + writer.write_u16::(*sample)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stdp version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stdp flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stsc.rs b/video/container/mp4/src/boxes/types/stsc.rs new file mode 100644 index 00000000..8cd1dee7 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stsc.rs @@ -0,0 +1,98 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample To Chunk Box +/// ISO/IEC 14496-12:2022(E) - 8.7.4 +pub struct Stsc { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Stsc { + pub fn new(entries: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Sample To Chunk Entry +pub struct StscEntry { + pub first_chunk: u32, + pub samples_per_chunk: u32, + pub sample_description_index: u32, +} + +impl BoxType for Stsc { + const NAME: [u8; 4] = *b"stsc"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let first_chunk = reader.read_u32::()?; + let samples_per_chunk = reader.read_u32::()?; + let sample_description_index = reader.read_u32::()?; + + entries.push(StscEntry { + first_chunk, + samples_per_chunk, + sample_description_index, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + // entries + size + (self.entries.len() as u64 * 12) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for entry in &self.entries { + writer.write_u32::(entry.first_chunk)?; + writer.write_u32::(entry.samples_per_chunk)?; + writer.write_u32::(entry.sample_description_index)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsc box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsc box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stsd.rs b/video/container/mp4/src/boxes/types/stsd.rs new file mode 100644 index 00000000..17efaaa0 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stsd.rs @@ -0,0 +1,451 @@ +use std::{ + fmt::Debug, + io::{self, Read, Write}, +}; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, + DynBox, +}; + +use super::{clap::Clap, colr::Colr, pasp::Pasp}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Description Box +/// ISO/IEC 14496-12:2022(E) - 8.5.2 +pub struct Stsd { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Stsd { + pub fn new(entries: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries, + } + } + + pub fn get_codecs(&self) -> impl Iterator + '_ { + self.entries.iter().filter_map(|e| match e { + DynBox::Av01(av01) => av01.codec().ok().map(|c| c.to_string()), + DynBox::Avc1(avc1) => avc1.codec().ok().map(|c| c.to_string()), + DynBox::Hev1(hev1) => hev1.codec().ok().map(|c| c.to_string()), + DynBox::Opus(opus) => opus.codec().ok().map(|c| c.to_string()), + DynBox::Mp4a(mp4a) => mp4a.codec().ok().map(|c| c.to_string()), + _ => None, + }) + } + + pub fn is_audio(&self) -> bool { + self.entries + .iter() + .any(|e| matches!(e, DynBox::Mp4a(_) | DynBox::Opus(_))) + } + + pub fn is_video(&self) -> bool { + self.entries + .iter() + .any(|e| matches!(e, DynBox::Av01(_) | DynBox::Avc1(_) | DynBox::Hev1(_))) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Sample Entry Box +/// Contains a template field for the Type of Sample Entry +/// ISO/IEC 14496-12:2022(E) - 8.5.2.2 +pub struct SampleEntry { + pub reserved: [u8; 6], + pub data_reference_index: u16, + pub extension: T, +} + +impl SampleEntry { + pub fn new(extension: T) -> Self { + Self { + reserved: [0; 6], + data_reference_index: 1, + extension, + } + } +} + +pub trait SampleEntryExtension: Debug + Clone + PartialEq { + fn demux(reader: &mut R) -> io::Result + where + Self: Sized; + + fn size(&self) -> u64; + + fn mux(&self, writer: &mut W) -> io::Result<()>; + + fn validate(&self) -> io::Result<()> { + Ok(()) + } +} + +impl SampleEntry { + pub fn demux(reader: &mut R) -> io::Result { + let mut reserved = [0; 6]; + reader.read_exact(&mut reserved)?; + + let data_reference_index = reader.read_u16::()?; + + Ok(Self { + reserved, + data_reference_index, + extension: T::demux(reader)?, + }) + } + + pub fn size(&self) -> u64 { + 6 // reserved + + 2 // data_reference_index + + self.extension.size() + } + + pub fn mux(&self, writer: &mut W) -> io::Result<()> { + self.validate()?; + + writer.write_all(&self.reserved)?; + writer.write_u16::(self.data_reference_index)?; + self.extension.mux(writer)?; + + Ok(()) + } + + pub fn validate(&self) -> io::Result<()> { + if self.reserved != [0; 6] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sample entry reserved field must be 0", + )); + } + + self.extension.validate()?; + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Audio Sample Entry Contents +/// ISO/IEC 14496-12:2022(E) - 12.2.3.2 +pub struct AudioSampleEntry { + pub reserved: [u32; 2], + pub channel_count: u16, + pub sample_size: u16, + pub pre_defined: u16, + pub reserved2: u16, + pub sample_rate: u32, +} + +impl AudioSampleEntry { + pub fn new(channel_count: u16, sample_size: u16, sample_rate: u32) -> Self { + Self { + reserved: [0, 0], + channel_count, + sample_size, + pre_defined: 0, + reserved2: 0, + sample_rate, + } + } +} + +impl SampleEntryExtension for AudioSampleEntry { + fn demux(reader: &mut T) -> io::Result { + let reserved = [ + reader.read_u32::()?, + reader.read_u32::()?, + ]; + + let channel_count = reader.read_u16::()?; + let sample_size = reader.read_u16::()?; + let pre_defined = reader.read_u16::()?; + let reserved2 = reader.read_u16::()?; + let sample_rate = reader.read_u32::()? >> 16; + + Ok(Self { + reserved, + channel_count, + sample_size, + pre_defined, + reserved2, + sample_rate, + }) + } + + fn size(&self) -> u64 { + 4 // reserved[0] + + 4 // reserved[1] + + 2 // channel_count + + 2 // sample_size + + 2 // pre_defined + + 2 // reserved2 + + 4 // sample_rate + } + + fn mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u32::(self.reserved[0])?; + writer.write_u32::(self.reserved[1])?; + writer.write_u16::(self.channel_count)?; + writer.write_u16::(self.sample_size)?; + writer.write_u16::(self.pre_defined)?; + writer.write_u16::(self.reserved2)?; + writer.write_u32::(self.sample_rate << 16)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.reserved != [0, 0] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "reserved field must be 0", + )); + } + + if self.pre_defined != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pre_defined field must be 0", + )); + } + + if self.reserved2 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "reserved2 field must be 0", + )); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Visual Sample Entry Contents +/// ISO/IEC 14496-12:2022(E) - 12.1.3.2 +pub struct VisualSampleEntry { + pub pre_defined: u16, + pub reserved: u16, + pub pre_defined2: [u32; 3], + pub width: u16, + pub height: u16, + pub horizresolution: u32, + pub vertresolution: u32, + pub reserved2: u32, + pub frame_count: u16, + pub compressorname: [u8; 32], + pub depth: u16, + pub pre_defined3: i16, + pub clap: Option, + pub colr: Option, + pub pasp: Option, +} + +impl VisualSampleEntry { + pub fn new(width: u16, height: u16, colr: Option) -> Self { + Self { + pre_defined: 0, + reserved: 0, + pre_defined2: [0, 0, 0], + width, + height, + horizresolution: 0x00480000, + vertresolution: 0x00480000, + reserved2: 0, + frame_count: 1, + compressorname: [0; 32], + depth: 0x0018, + pre_defined3: -1, + clap: None, + colr, + pasp: Some(Pasp::new()), + } + } +} + +impl SampleEntryExtension for VisualSampleEntry { + fn demux(reader: &mut T) -> io::Result { + let pre_defined = reader.read_u16::()?; + let reserved = reader.read_u16::()?; + let pre_defined2 = [ + reader.read_u32::()?, + reader.read_u32::()?, + reader.read_u32::()?, + ]; + let width = reader.read_u16::()?; + let height = reader.read_u16::()?; + let horizresolution = reader.read_u32::()?; + let vertresolution = reader.read_u32::()?; + let reserved2 = reader.read_u32::()?; + let frame_count = reader.read_u16::()?; + let mut compressorname = [0; 32]; + reader.read_exact(&mut compressorname)?; + let depth = reader.read_u16::()?; + let pre_defined3 = reader.read_i16::()?; + + Ok(Self { + pre_defined, + reserved, + pre_defined2, + width, + height, + horizresolution, + vertresolution, + reserved2, + frame_count, + compressorname, + depth, + pre_defined3, + colr: None, + clap: None, + pasp: None, + }) + } + + fn size(&self) -> u64 { + 2 // pre_defined + + 2 // reserved + + 4 // pre_defined2[0] + + 4 // pre_defined2[1] + + 4 // pre_defined2[2] + + 2 // width + + 2 // height + + 4 // horizresolution + + 4 // vertresolution + + 4 // reserved2 + + 2 // frame_count + + 32 // compressorname + + 2 // depth + + 2 // pre_defined3 + + self.clap.as_ref().map_or(0, |clap| clap.size()) + + self.pasp.as_ref().map_or(0, |pasp| pasp.size()) + + self.colr.as_ref().map_or(0, |colr| colr.size()) + } + + fn mux(&self, writer: &mut T) -> io::Result<()> { + writer.write_u16::(self.pre_defined).unwrap(); + writer.write_u16::(self.reserved).unwrap(); + writer.write_u32::(self.pre_defined2[0]).unwrap(); + writer.write_u32::(self.pre_defined2[1]).unwrap(); + writer.write_u32::(self.pre_defined2[2]).unwrap(); + writer.write_u16::(self.width).unwrap(); + writer.write_u16::(self.height).unwrap(); + writer.write_u32::(self.horizresolution).unwrap(); + writer.write_u32::(self.vertresolution).unwrap(); + writer.write_u32::(self.reserved2).unwrap(); + writer.write_u16::(self.frame_count).unwrap(); + writer.write_all(&self.compressorname).unwrap(); + writer.write_u16::(self.depth).unwrap(); + writer.write_i16::(self.pre_defined3).unwrap(); + if let Some(clap) = &self.clap { + clap.mux(writer).unwrap(); + } + if let Some(pasp) = &self.pasp { + pasp.mux(writer).unwrap(); + } + if let Some(colr) = &self.colr { + colr.mux(writer).unwrap(); + } + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.pre_defined != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pre_defined field must be 0", + )); + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "reserved field must be 0", + )); + } + + if self.pre_defined2 != [0, 0, 0] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pre_defined2 field must be 0", + )); + } + + if self.reserved2 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "reserved2 field must be 0", + )); + } + + if self.pre_defined3 != -1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pre_defined3 field must be -1", + )); + } + + Ok(()) + } +} + +impl BoxType for Stsd { + const NAME: [u8; 4] = *b"stsd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + + for _ in 0..entry_count { + let entry = DynBox::demux(&mut reader)?; + entries.push(entry); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // entry_count + + self.entries.iter().map(|entry| entry.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + writer.write_u32::(self.entries.len() as u32)?; + for entry in &self.entries { + entry.mux(writer)?; + } + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsd flags must be 0", + )); + } + + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsd version must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stsh.rs b/video/container/mp4/src/boxes/types/stsh.rs new file mode 100644 index 00000000..7f9de420 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stsh.rs @@ -0,0 +1,85 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Shadow Sync Sample Box +/// ISO/IEC 14496-12:2022(E) - 8.6.3 +pub struct Stsh { + pub header: FullBoxHeader, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Shadow Sync Sample Entry +pub struct StshEntry { + pub shadowed_sample_count: u32, + pub sync_sample_number: u32, +} + +impl BoxType for Stsh { + const NAME: [u8; 4] = *b"stsh"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let shadowed_sample_count = reader.read_u32::()?; + let sync_sample_number = reader.read_u32::()?; + + entries.push(StshEntry { + shadowed_sample_count, + sync_sample_number, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + // entries + size + (self.entries.len() as u64 * 8) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for entry in &self.entries { + writer.write_u32::(entry.shadowed_sample_count)?; + writer.write_u32::(entry.sync_sample_number)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsh version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsh flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stss.rs b/video/container/mp4/src/boxes/types/stss.rs new file mode 100644 index 00000000..b32456a8 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stss.rs @@ -0,0 +1,72 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sync Sample Box +/// ISO/IEC 14496-12:2022(E) - 8.6.2 +pub struct Stss { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl BoxType for Stss { + const NAME: [u8; 4] = *b"stss"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let offset = reader.read_u32::()?; + entries.push(offset); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + // entries + size + (self.entries.len() as u64 * 4) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for offset in &self.entries { + writer.write_u32::(*offset)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stss version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stss flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stsz.rs b/video/container/mp4/src/boxes/types/stsz.rs new file mode 100644 index 00000000..176e57b3 --- /dev/null +++ b/video/container/mp4/src/boxes/types/stsz.rs @@ -0,0 +1,106 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Sample Size Box +/// ISO/IEC 14496-12:2022(E) - 8.7.3.2 +pub struct Stsz { + pub header: FullBoxHeader, + pub sample_size: u32, + pub samples: Vec, +} + +impl Stsz { + pub fn new(sample_size: u32, samples: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + sample_size, + samples, + } + } +} + +impl BoxType for Stsz { + const NAME: [u8; 4] = *b"stsz"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let sample_size = reader.read_u32::()?; + let sample_count = reader.read_u32::()?; + + let mut samples = Vec::with_capacity(sample_count as usize); + if sample_size == 0 { + for _ in 0..sample_count { + let size = reader.read_u32::()?; + samples.push(size); + } + } + + Ok(Self { + header, + sample_size, + samples, + }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 8; // sample_size + sample_count + + if self.sample_size == 0 { + size + (self.samples.len() as u64 * 4) // samples + } else { + size + } + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.sample_size)?; + writer.write_u32::(self.samples.len() as u32)?; + + if self.sample_size == 0 { + for size in &self.samples { + writer.write_u32::(*size)?; + } + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.sample_size != 0 && !self.samples.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsz: sample_size is not 0 but samples are present", + )); + } + + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsz box version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stsz box flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stts.rs b/video/container/mp4/src/boxes/types/stts.rs new file mode 100644 index 00000000..2bddfdea --- /dev/null +++ b/video/container/mp4/src/boxes/types/stts.rs @@ -0,0 +1,93 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Decoding Time to Sample Box +/// ISO/IEC 14496-12:2022(E) - 8.6.1.2 +pub struct Stts { + pub header: FullBoxHeader, + pub entries: Vec, +} + +impl Stts { + pub fn new(entries: Vec) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + entries, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Decoding Time to Sample Box Entry +pub struct SttsEntry { + pub sample_count: u32, + pub sample_delta: u32, +} + +impl BoxType for Stts { + const NAME: [u8; 4] = *b"stts"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + for _ in 0..entry_count { + let sample_count = reader.read_u32::()?; + let sample_delta = reader.read_u32::()?; + entries.push(SttsEntry { + sample_count, + sample_delta, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + // entries + size + (self.entries.len() as u64 * 8) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for entry in &self.entries { + writer.write_u32::(entry.sample_count)?; + writer.write_u32::(entry.sample_delta)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stts version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stts flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/stz2.rs b/video/container/mp4/src/boxes/types/stz2.rs new file mode 100644 index 00000000..373f205e --- /dev/null +++ b/video/container/mp4/src/boxes/types/stz2.rs @@ -0,0 +1,159 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Compact Sample Size Box +/// ISO/IEC 14496-12:2022(E) - 8.7.3.3 +pub struct Stz2 { + pub header: FullBoxHeader, + pub reserved: u32, + pub field_size: u8, + pub samples: Vec, +} + +impl BoxType for Stz2 { + const NAME: [u8; 4] = *b"stz2"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let reserved = reader.read_u24::()?; + + let field_size = reader.read_u8()?; + let sample_count = reader.read_u32::()?; + + let mut samples = Vec::with_capacity(sample_count as usize); + + let mut sample_idx = 0; + while sample_idx < sample_count { + let sample = match field_size { + 4 => { + let byte = reader.read_u8()?; + samples.push((byte >> 4) as u16); + sample_idx += 1; + if sample_idx >= sample_count { + break; + } + + (byte & 0x0F) as u16 + } + 8 => reader.read_u8()? as u16, + 16 => reader.read_u16::()?, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid field size", + )) + } + }; + + sample_idx += 1; + samples.push(sample); + } + + Ok(Self { + header, + reserved, + field_size, + samples, + }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 8; // reserved + field_size + sample_count + + size + (self.samples.len() as u64 * self.field_size as u64) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u24::(self.reserved)?; + writer.write_u8(self.field_size)?; + writer.write_u32::(self.samples.len() as u32)?; + + let mut sample_idx = 0; + while sample_idx < self.samples.len() { + let sample = self.samples[sample_idx]; + match self.field_size { + 4 => { + let byte = (sample << 4) as u8; + sample_idx += 1; + if sample_idx >= self.samples.len() { + writer.write_u8(byte)?; + break; + } + + let sample = self.samples[sample_idx]; + let byte = byte | (sample & 0x0F) as u8; + writer.write_u8(byte)?; + } + 8 => writer.write_u8(sample as u8)?, + 16 => writer.write_u16::(sample)?, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid field size", + )) + } + }; + + sample_idx += 1; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stz2 version must be 0", + )); + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stz2 reserved must be 0", + )); + } + + if self.field_size != 4 && self.field_size != 8 && self.field_size != 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stz2 field_size must be 4, 8 or 16", + )); + } + + if self.field_size != 16 { + for sample in &self.samples { + if self.field_size == 4 { + if *sample > 0x0F { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stz2 sample value must be 4 bits", + )); + } + } else if self.field_size == 8 && *sample > 0xFF { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stz2 sample value must be 8 bits", + )); + } + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/subs.rs b/video/container/mp4/src/boxes/types/subs.rs new file mode 100644 index 00000000..72fd77eb --- /dev/null +++ b/video/container/mp4/src/boxes/types/subs.rs @@ -0,0 +1,145 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Subsample Information Box +/// ISO/IEC 14496-12:2022(E) - 8.7.7 +pub struct Subs { + pub header: FullBoxHeader, + + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Subs box entry +pub struct SubsEntry { + pub sample_delta: u32, + pub subsamples: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +/// Sub Sample Entry +pub struct SubSampleEntry { + pub subsample_size: u32, + pub subsample_priority: u8, + pub discardable: u8, + pub codec_specific_parameters: u32, +} + +impl BoxType for Subs { + const NAME: [u8; 4] = *b"subs"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let entry_count = reader.read_u32::()?; + let mut entries = Vec::with_capacity(entry_count as usize); + + for _ in 0..entry_count { + let sample_delta = reader.read_u32::()?; + let subsample_count = reader.read_u16::()?; + let mut subsamples = Vec::with_capacity(subsample_count as usize); + + for _ in 0..subsample_count { + let subsample_size = if header.version == 1 { + reader.read_u32::()? + } else { + reader.read_u16::()? as u32 + }; + let subsample_priority = reader.read_u8()?; + let discardable = reader.read_u8()?; + let codec_specific_parameters = reader.read_u32::()?; + subsamples.push(SubSampleEntry { + subsample_size, + subsample_priority, + discardable, + codec_specific_parameters, + }); + } + + entries.push(SubsEntry { + sample_delta, + subsamples, + }); + } + + Ok(Self { header, entries }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // entry_count + let size = size + + self + .entries + .iter() + .map(|e| { + let size = 4; // sample_delta + let size = size + 2; // subsample_count + + size + e.subsamples.len() as u64 + * if self.header.version == 1 { + 4 + 1 + 1 + 4 + } else { + 2 + 1 + 1 + 4 + } + }) + .sum::(); // entries + size + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.entries.len() as u32)?; + for entry in &self.entries { + writer.write_u32::(entry.sample_delta)?; + writer.write_u16::(entry.subsamples.len() as u16)?; + for subsample in &entry.subsamples { + if self.header.version == 1 { + writer.write_u32::(subsample.subsample_size)?; + } else { + writer.write_u16::(subsample.subsample_size as u16)?; + } + writer.write_u8(subsample.subsample_priority)?; + writer.write_u8(subsample.discardable)?; + writer.write_u32::(subsample.codec_specific_parameters)?; + } + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "subs version must be 0 or 1", + )); + } + + if self.header.version == 0 { + for entry in &self.entries { + for subsample in &entry.subsamples { + if subsample.subsample_size > u16::MAX as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "subs subsample_size must be less than 2^16", + )); + } + } + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/tfdt.rs b/video/container/mp4/src/boxes/types/tfdt.rs new file mode 100644 index 00000000..354db5e4 --- /dev/null +++ b/video/container/mp4/src/boxes/types/tfdt.rs @@ -0,0 +1,94 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Fragment Base Media Decode Time Box +/// ISO/IEC 14496-12:2022(E) - 8.8.12 +pub struct Tfdt { + pub header: FullBoxHeader, + pub base_media_decode_time: u64, +} + +impl Tfdt { + pub fn new(base_media_decode_time: u64) -> Self { + let version = if base_media_decode_time > u32::MAX as u64 { + 1 + } else { + 0 + }; + + Self { + header: FullBoxHeader::new(Self::NAME, version, 0), + base_media_decode_time, + } + } +} + +impl BoxType for Tfdt { + const NAME: [u8; 4] = *b"tfdt"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let base_media_decode_time = if header.version == 1 { + reader.read_u64::()? + } else { + reader.read_u32::()? as u64 + }; + + Ok(Self { + header, + base_media_decode_time, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + if self.header.version == 1 { 8 } else { 4 } + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if self.header.version == 1 { + writer.write_u64::(self.base_media_decode_time)?; + } else { + writer.write_u32::(self.base_media_decode_time as u32)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfdt version must be 0 or 1", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfdt flags must be 0", + )); + } + + if self.header.version == 0 && self.base_media_decode_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfdt base_data_offset must be less than 2^32", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/tfhd.rs b/video/container/mp4/src/boxes/types/tfhd.rs new file mode 100644 index 00000000..dd0537f8 --- /dev/null +++ b/video/container/mp4/src/boxes/types/tfhd.rs @@ -0,0 +1,246 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +use super::trun::TrunSampleFlag; + +#[derive(Debug, Clone, PartialEq)] +/// Track Fragment Header Box +/// ISO/IEC 14496-12:2022(E) - 8.8.7 +pub struct Tfhd { + pub header: FullBoxHeader, + pub track_id: u32, + pub base_data_offset: Option, + pub sample_description_index: Option, + pub default_sample_duration: Option, + pub default_sample_size: Option, + pub default_sample_flags: Option, +} + +impl Tfhd { + pub const BASE_DATA_OFFSET_FLAG: u32 = 0x000001; + pub const SAMPLE_DESCRIPTION_INDEX_FLAG: u32 = 0x000002; + pub const DEFAULT_SAMPLE_DURATION_FLAG: u32 = 0x000008; + pub const DEFAULT_SAMPLE_SIZE_FLAG: u32 = 0x000010; + pub const DEFAULT_SAMPLE_FLAGS_FLAG: u32 = 0x000020; + pub const DURATION_IS_EMPTY_FLAG: u32 = 0x010000; + pub const DEFAULT_BASE_IS_MOOF_FLAG: u32 = 0x020000; + + pub fn new( + track_id: u32, + base_data_offset: Option, + sample_description_index: Option, + default_sample_duration: Option, + default_sample_size: Option, + default_sample_flags: Option, + ) -> Self { + let flags = if base_data_offset.is_some() { + Self::BASE_DATA_OFFSET_FLAG + } else { + 0 + } | if sample_description_index.is_some() { + Self::SAMPLE_DESCRIPTION_INDEX_FLAG + } else { + 0 + } | if default_sample_duration.is_some() { + Self::DEFAULT_SAMPLE_DURATION_FLAG + } else { + 0 + } | if default_sample_size.is_some() { + Self::DEFAULT_SAMPLE_SIZE_FLAG + } else { + 0 + } | if default_sample_flags.is_some() { + Self::DEFAULT_SAMPLE_FLAGS_FLAG + } else { + 0 + } | Self::DEFAULT_BASE_IS_MOOF_FLAG; + + Self { + header: FullBoxHeader::new(Self::NAME, 0, flags), + track_id, + base_data_offset, + sample_description_index, + default_sample_duration, + default_sample_size, + default_sample_flags, + } + } +} + +impl BoxType for Tfhd { + const NAME: [u8; 4] = *b"tfhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let track_id = reader.read_u32::()?; + + let base_data_offset = if header.flags & Self::BASE_DATA_OFFSET_FLAG != 0 { + Some(reader.read_u64::()?) + } else { + None + }; + + let sample_description_index = if header.flags & Self::SAMPLE_DESCRIPTION_INDEX_FLAG != 0 { + Some(reader.read_u32::()?) + } else { + None + }; + + let default_sample_duration = if header.flags & Self::DEFAULT_SAMPLE_DURATION_FLAG != 0 { + Some(reader.read_u32::()?) + } else { + None + }; + + let default_sample_size = if header.flags & Self::DEFAULT_SAMPLE_SIZE_FLAG != 0 { + Some(reader.read_u32::()?) + } else { + None + }; + + let default_sample_flags = if header.flags & Self::DEFAULT_SAMPLE_FLAGS_FLAG != 0 { + Some(reader.read_u32::()?.into()) + } else { + None + }; + + Ok(Self { + header, + track_id, + base_data_offset, + sample_description_index, + default_sample_duration, + default_sample_size, + default_sample_flags, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 + + self.base_data_offset.map_or(0, |_| 8) + + self.sample_description_index.map_or(0, |_| 4) + + self.default_sample_duration.map_or(0, |_| 4) + + self.default_sample_size.map_or(0, |_| 4) + + self.default_sample_flags.map_or(0, |_| 4) + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.track_id)?; + + if let Some(base_data_offset) = self.base_data_offset { + writer.write_u64::(base_data_offset)?; + } + + if let Some(sample_description_index) = self.sample_description_index { + writer.write_u32::(sample_description_index)?; + } + + if let Some(default_sample_duration) = self.default_sample_duration { + writer.write_u32::(default_sample_duration)?; + } + + if let Some(default_sample_size) = self.default_sample_size { + writer.write_u32::(default_sample_size)?; + } + + if let Some(default_sample_flags) = self.default_sample_flags { + writer.write_u32::(default_sample_flags.into())?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd version must be 0", + )); + } + + if self.header.flags & Self::BASE_DATA_OFFSET_FLAG != 0 && self.base_data_offset.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd base_data_offset flag is set but base_data_offset is not present", + )); + } else if self.header.flags & Self::BASE_DATA_OFFSET_FLAG == 0 + && self.base_data_offset.is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd base_data_offset flag is not set but base_data_offset is present", + )); + } + + if self.header.flags & Self::SAMPLE_DESCRIPTION_INDEX_FLAG != 0 + && self.sample_description_index.is_none() + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "tfhd sample_description_index flag is set but sample_description_index is not present")); + } else if self.header.flags & Self::SAMPLE_DESCRIPTION_INDEX_FLAG == 0 + && self.sample_description_index.is_some() + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "tfhd sample_description_index flag is not set but sample_description_index is present")); + } + + if self.header.flags & Self::DEFAULT_SAMPLE_DURATION_FLAG != 0 + && self.default_sample_duration.is_none() + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "tfhd default_sample_duration flag is set but default_sample_duration is not present")); + } else if self.header.flags & Self::DEFAULT_SAMPLE_DURATION_FLAG == 0 + && self.default_sample_duration.is_some() + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "tfhd default_sample_duration flag is not set but default_sample_duration is present")); + } + + if self.header.flags & Self::DEFAULT_SAMPLE_SIZE_FLAG != 0 + && self.default_sample_size.is_none() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd default_sample_size flag is set but default_sample_size is not present", + )); + } else if self.header.flags & Self::DEFAULT_SAMPLE_SIZE_FLAG == 0 + && self.default_sample_size.is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd default_sample_size flag is not set but default_sample_size is present", + )); + } + + if self.header.flags & Self::DEFAULT_SAMPLE_FLAGS_FLAG != 0 + && self.default_sample_flags.is_none() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd default_sample_flags flag is set but default_sample_flags is not present", + )); + } else if self.header.flags & Self::DEFAULT_SAMPLE_FLAGS_FLAG == 0 + && self.default_sample_flags.is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tfhd default_sample_flags flag is not set but default_sample_flags is present", + )); + } + + if let Some(default_sample_flags) = self.default_sample_flags { + default_sample_flags.validate()?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/tkhd.rs b/video/container/mp4/src/boxes/types/tkhd.rs new file mode 100644 index 00000000..b00cd582 --- /dev/null +++ b/video/container/mp4/src/boxes/types/tkhd.rs @@ -0,0 +1,271 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use fixed::{ + types::extra::{U16, U8}, + FixedI16, FixedI32, +}; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Header Box +/// ISO/IEC 14496-12:2022(E) - 8.3.2 +pub struct Tkhd { + pub header: FullBoxHeader, + pub creation_time: u64, + pub modification_time: u64, + pub track_id: u32, + pub reserved: u32, + pub duration: u64, + pub reserved2: [u32; 2], + pub layer: u16, + pub alternate_group: u16, + pub volume: FixedI16, + pub reserved3: u16, + pub matrix: [u32; 9], + pub width: FixedI32, + pub height: FixedI32, +} + +impl Tkhd { + pub fn new( + creation_time: u64, + modification_time: u64, + track_id: u32, + duration: u64, + width_height: Option<(u32, u32)>, + ) -> Self { + let version = if creation_time > u32::MAX as u64 + || modification_time > u32::MAX as u64 + || duration > u32::MAX as u64 + { + 1 + } else { + 0 + }; + + let (width, height) = width_height.unwrap_or((0, 0)); + let volume = if width_height.is_some() { + FixedI16::::from_num(0) + } else { + FixedI16::::from_num(1) + }; + + Self { + header: FullBoxHeader::new( + Self::NAME, + version, + Self::TRACK_ENABLED_FLAG | Self::TRACK_IN_MOVIE_FLAG, + ), + creation_time, + modification_time, + track_id, + reserved: 0, + duration, + reserved2: [0; 2], + layer: 0, + alternate_group: 0, + volume, + reserved3: 0, + matrix: Self::MATRIX, + width: FixedI32::::from_num(width), + height: FixedI32::::from_num(height), + } + } +} + +impl Tkhd { + pub const TRACK_ENABLED_FLAG: u32 = 0x000001; + pub const TRACK_IN_MOVIE_FLAG: u32 = 0x000002; + pub const TRACK_IN_PREVIEW_FLAG: u32 = 0x000004; + pub const TRACK_SIZE_IS_ASPECT_RATIO_FLAG: u32 = 0x000008; + pub const MATRIX: [u32; 9] = [0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000]; +} + +impl BoxType for Tkhd { + const NAME: [u8; 4] = *b"tkhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let (creation_time, modification_time, track_id, reserved, duration) = + if header.version == 1 { + ( + reader.read_u64::()?, // creation_time + reader.read_u64::()?, // modification_time + reader.read_u32::()?, // track_id + reader.read_u32::()?, // reserved + reader.read_u64::()?, // duration + ) + } else { + ( + reader.read_u32::()? as u64, // creation_time + reader.read_u32::()? as u64, // modification_time + reader.read_u32::()?, // track_id + reader.read_u32::()?, // reserved + reader.read_u32::()? as u64, // duration + ) + }; + + let mut reserved2 = [0; 2]; + for v in reserved2.iter_mut() { + *v = reader.read_u32::()?; + } + + let layer = reader.read_u16::()?; + let alternate_group = reader.read_u16::()?; + let volume = reader.read_i16::()?; + + let reserved3 = reader.read_u16::()?; + + let mut matrix = [0; 9]; + for v in matrix.iter_mut() { + *v = reader.read_u32::()?; + } + + let width = reader.read_i32::()?; + let height = reader.read_i32::()?; + + Ok(Self { + header, + creation_time, + modification_time, + track_id, + reserved, + duration, + reserved2, + layer, + alternate_group, + volume: FixedI16::::from_bits(volume), + reserved3, + matrix, + width: FixedI32::::from_bits(width), + height: FixedI32::::from_bits(height), + }) + } + + fn primitive_size(&self) -> u64 { + let mut size = self.header.size(); + size += if self.header.version == 1 { + 8 + 8 + 4 + 4 + 8 // creation_time, modification_time, track_id, reserved, duration + } else { + 4 + 4 + 4 + 4 + 4 // creation_time, modification_time, track_id, reserved, duration + }; + + size += 4 * 2; // reserved2 + size += 2; // layer + size += 2; // alternate_group + size += 2; // volume + size += 2; // reserved + size += 4 * 9; // matrix + size += 4; // width + size += 4; // height + + size + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if self.header.version == 1 { + writer.write_u64::(self.creation_time)?; + writer.write_u64::(self.modification_time)?; + writer.write_u32::(self.track_id)?; + writer.write_u32::(self.reserved)?; + writer.write_u64::(self.duration)?; + } else { + writer.write_u32::(self.creation_time as u32)?; + writer.write_u32::(self.modification_time as u32)?; + writer.write_u32::(self.track_id)?; + writer.write_u32::(self.reserved)?; + writer.write_u32::(self.duration as u32)?; + } + + for v in self.reserved2.iter() { + writer.write_u32::(*v)?; + } + + writer.write_u16::(self.layer)?; + writer.write_u16::(self.alternate_group)?; + writer.write_i16::(self.volume.to_bits())?; + writer.write_u16::(self.reserved3)?; + + for v in self.matrix.iter() { + writer.write_u32::(*v)?; + } + + writer.write_i32::(self.width.to_bits())?; + writer.write_i32::(self.height.to_bits())?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd version must be 0 or 1", + )); + } + + if self.track_id == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd track_id must not be 0", + )); + } + + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd reserved must be 0", + )); + } + + if self.reserved2 != [0; 2] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd reserved2 must be 0", + )); + } + + if self.reserved3 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd reserved3 must be 0", + )); + } + + if self.header.version == 0 { + if self.creation_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd creation_time must be less than 2^32", + )); + } + + if self.modification_time > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd modification_time must be less than 2^32", + )); + } + + if self.duration > u32::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "tkhd duration must be less than 2^32", + )); + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/traf.rs b/video/container/mp4/src/boxes/types/traf.rs new file mode 100644 index 00000000..4a8e77e3 --- /dev/null +++ b/video/container/mp4/src/boxes/types/traf.rs @@ -0,0 +1,281 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{sbgp::Sbgp, subs::Subs, tfdt::Tfdt, tfhd::Tfhd, trun::Trun}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Fragment Box +/// ISO/IEC 14496-12:2022(E) - 8.8.6 +pub struct Traf { + pub header: BoxHeader, + pub tfhd: Tfhd, + pub trun: Option, + pub sbgp: Option, + pub subs: Option, + pub tfdt: Option, + pub unknown: Vec, +} + +impl Traf { + pub fn new(tfhd: Tfhd, trun: Option, tfdt: Option) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + tfhd, + trun, + sbgp: None, + subs: None, + tfdt, + unknown: Vec::new(), + } + } + + /// This function will try to optimize the trun samples by using default values + /// from the tfhd box. + pub fn optimize(&mut self) { + let Some(trun) = &mut self.trun else { + return; + }; + + if trun.samples.is_empty() { + return; + } + + let tfhd = &mut self.tfhd; + + if tfhd.default_sample_flags.is_none() + && trun.samples.len() > 1 + && trun + .samples + .iter() + .skip(2) + .all(|s| s.flags == trun.samples[1].flags) + { + tfhd.default_sample_flags = trun.samples[1].flags; + + let first_sample = trun.samples.first().unwrap(); + if first_sample.flags != tfhd.default_sample_flags { + trun.first_sample_flags = first_sample.flags; + } + + trun.samples.iter_mut().for_each(|s| s.flags = None); + } + + if trun + .samples + .iter() + .all(|s| s.composition_time_offset == Some(0)) + { + trun.samples + .iter_mut() + .for_each(|s| s.composition_time_offset = None); + } + + if tfhd.default_sample_duration.is_none() + && trun + .samples + .iter() + .skip(1) + .all(|s| s.duration == trun.samples[0].duration) + { + tfhd.default_sample_duration = trun.samples[0].duration; + trun.samples.iter_mut().for_each(|s| s.duration = None); + } + + if tfhd.default_sample_size.is_none() + && trun + .samples + .iter() + .skip(1) + .all(|s| s.size == trun.samples[0].size) + { + tfhd.default_sample_size = trun.samples[0].size; + trun.samples.iter_mut().for_each(|s| s.size = None); + } + + trun.header.flags = if trun.data_offset.is_some() { + Trun::FLAG_DATA_OFFSET + } else { + 0 + } | if trun.first_sample_flags.is_some() { + Trun::FLAG_FIRST_SAMPLE_FLAGS + } else { + 0 + } | if trun.samples.get(0).and_then(|s| s.duration).is_some() { + Trun::FLAG_SAMPLE_DURATION + } else { + 0 + } | if trun.samples.get(0).and_then(|s| s.size).is_some() { + Trun::FLAG_SAMPLE_SIZE + } else { + 0 + } | if trun.samples.get(0).and_then(|s| s.flags).is_some() { + Trun::FLAG_SAMPLE_FLAGS + } else { + 0 + } | if trun + .samples + .get(0) + .and_then(|s| s.composition_time_offset) + .is_some() + { + Trun::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET + } else { + 0 + }; + + tfhd.header.flags = if tfhd.base_data_offset.is_some() { + Tfhd::BASE_DATA_OFFSET_FLAG + } else { + 0 + } | if tfhd.default_sample_duration.is_some() { + Tfhd::DEFAULT_SAMPLE_DURATION_FLAG + } else { + 0 + } | if tfhd.default_sample_flags.is_some() { + Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + } else { + 0 + } | if tfhd.default_sample_size.is_some() { + Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + } else { + 0 + } | tfhd.header.flags & Tfhd::DEFAULT_BASE_IS_MOOF_FLAG; + } + + pub fn duration(&self) -> u32 { + let tfhd = &self.tfhd; + let trun = &self.trun; + + if let Some(trun) = trun { + let mut duration = 0; + for sample in &trun.samples { + if let Some(d) = sample.duration { + duration += d; + } else if let Some(d) = tfhd.default_sample_duration { + duration += d; + } + } + + return duration; + } + + 0 + } + + pub fn contains_keyframe(&self) -> bool { + let tfhd = &self.tfhd; + let trun = &self.trun; + + if let Some(trun) = trun { + if let Some(flags) = trun.first_sample_flags { + if flags.sample_depends_on == 2 { + return true; + } + } + + for sample in &trun.samples { + if let Some(flags) = sample.flags { + if flags.sample_depends_on == 2 { + return true; + } + } else if let Some(flags) = tfhd.default_sample_flags { + if flags.sample_depends_on == 2 { + return true; + } + } + } + } + + false + } +} + +impl BoxType for Traf { + const NAME: [u8; 4] = *b"traf"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let mut tfhd = None; + let mut trun = None; + let mut sbgp = None; + let mut subs = None; + let mut tfdt = None; + + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader)?; + match box_ { + DynBox::Tfhd(b) => { + tfhd = Some(b); + } + DynBox::Trun(b) => { + trun = Some(b); + } + DynBox::Sbgp(b) => { + sbgp = Some(b); + } + DynBox::Subs(b) => { + subs = Some(b); + } + DynBox::Tfdt(b) => { + tfdt = Some(b); + } + _ => unknown.push(box_), + } + } + + let tfhd = tfhd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "traf box must contain tfhd box") + })?; + + Ok(Self { + header, + tfhd, + trun, + sbgp, + subs, + tfdt, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.tfhd.size() + + self.trun.as_ref().map(|box_| box_.size()).unwrap_or(0) + + self.sbgp.as_ref().map(|box_| box_.size()).unwrap_or(0) + + self.subs.as_ref().map(|box_| box_.size()).unwrap_or(0) + + self.tfdt.as_ref().map(|box_| box_.size()).unwrap_or(0) + + self.unknown.iter().map(|box_| box_.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.tfhd.mux(writer)?; + + if let Some(box_) = &self.sbgp { + box_.mux(writer)?; + } + + if let Some(box_) = &self.subs { + box_.mux(writer)?; + } + + if let Some(box_) = &self.tfdt { + box_.mux(writer)?; + } + + for box_ in &self.unknown { + box_.mux(writer)?; + } + + if let Some(box_) = &self.trun { + box_.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/trak.rs b/video/container/mp4/src/boxes/types/trak.rs new file mode 100644 index 00000000..763ba540 --- /dev/null +++ b/video/container/mp4/src/boxes/types/trak.rs @@ -0,0 +1,99 @@ +use std::io; + +use bytes::{Buf, Bytes}; + +use crate::boxes::{header::BoxHeader, traits::BoxType, DynBox}; + +use super::{edts::Edts, mdia::Mdia, tkhd::Tkhd}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Box +/// ISO/IEC 14496-12:2022(E) - 8.3.1 +pub struct Trak { + pub header: BoxHeader, + pub tkhd: Tkhd, + pub edts: Option, + pub mdia: Mdia, + pub unknown: Vec, +} + +impl Trak { + pub fn new(tkhd: Tkhd, edts: Option, mdia: Mdia) -> Self { + Self { + header: BoxHeader::new(Self::NAME), + tkhd, + edts, + mdia, + unknown: Vec::new(), + } + } +} + +impl BoxType for Trak { + const NAME: [u8; 4] = *b"trak"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + let mut tkhd = None; + let mut edts = None; + let mut mdia = None; + let mut unknown = Vec::new(); + + while reader.has_remaining() { + let dyn_box = DynBox::demux(&mut reader)?; + + match dyn_box { + DynBox::Tkhd(b) => { + tkhd = Some(b); + } + DynBox::Edts(b) => { + edts = Some(b); + } + DynBox::Mdia(b) => { + mdia = Some(b); + } + _ => { + unknown.push(dyn_box); + } + } + } + + let tkhd = tkhd.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trak box is missing tkhd box") + })?; + + let mdia = mdia.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trak box is missing mdia box") + })?; + + Ok(Self { + header, + tkhd, + edts, + mdia, + unknown, + }) + } + + fn primitive_size(&self) -> u64 { + self.tkhd.size() + + self.edts.as_ref().map(|b| b.size()).unwrap_or(0) + + self.mdia.size() + + self.unknown.iter().map(|b| b.size()).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.tkhd.mux(writer)?; + if let Some(edts) = &self.edts { + edts.mux(writer)?; + } + + self.mdia.mux(writer)?; + + for box_ in &self.unknown { + box_.mux(writer)?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/trex.rs b/video/container/mp4/src/boxes/types/trex.rs new file mode 100644 index 00000000..21cce190 --- /dev/null +++ b/video/container/mp4/src/boxes/types/trex.rs @@ -0,0 +1,100 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Extends Box +/// ISO/IEC 14496-12:2022(E) - 8.8.3 +pub struct Trex { + pub header: FullBoxHeader, + pub track_id: u32, + pub default_sample_description_index: u32, + pub default_sample_duration: u32, + pub default_sample_size: u32, + pub default_sample_flags: u32, +} + +impl Trex { + pub fn new(track_id: u32) -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 0), + track_id, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + } + } +} + +impl BoxType for Trex { + const NAME: [u8; 4] = *b"trex"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let track_id = reader.read_u32::()?; + let default_sample_description_index = reader.read_u32::()?; + let default_sample_duration = reader.read_u32::()?; + let default_sample_size = reader.read_u32::()?; + let default_sample_flags = reader.read_u32::()?; + + Ok(Self { + header, + + track_id, + default_sample_description_index, + default_sample_duration, + default_sample_size, + default_sample_flags, + }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 4; // track_id + let size = size + 4; // default_sample_description_index + let size = size + 4; // default_sample_duration + let size = size + 4; // default_sample_size + // default_sample_flags + size + 4 + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.track_id)?; + writer.write_u32::(self.default_sample_description_index)?; + writer.write_u32::(self.default_sample_duration)?; + writer.write_u32::(self.default_sample_size)?; + writer.write_u32::(self.default_sample_flags)?; + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trex version must be 0", + )); + } + + if self.header.flags != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trex flags must be 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/trun.rs b/video/container/mp4/src/boxes/types/trun.rs new file mode 100644 index 00000000..c1f07a40 --- /dev/null +++ b/video/container/mp4/src/boxes/types/trun.rs @@ -0,0 +1,453 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Track Fragment Run Box +/// ISO/IEC 14496-12:2022(E) - 8.8.8 +pub struct Trun { + pub header: FullBoxHeader, + pub data_offset: Option, + pub first_sample_flags: Option, + pub samples: Vec, +} + +impl Trun { + pub fn new(samples: Vec, first_sample_flags: Option) -> Self { + let flags = if samples.is_empty() { + Self::FLAG_DATA_OFFSET + } else { + let mut flags = Self::FLAG_DATA_OFFSET; + if samples[0].duration.is_some() { + flags |= Self::FLAG_SAMPLE_DURATION; + } + if samples[0].size.is_some() { + flags |= Self::FLAG_SAMPLE_SIZE; + } + if samples[0].flags.is_some() { + flags |= Self::FLAG_SAMPLE_FLAGS; + } + if samples[0].composition_time_offset.is_some() { + flags |= Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET; + } + flags + }; + + let version = if samples + .iter() + .any(|s| s.composition_time_offset.is_some() && s.composition_time_offset.unwrap() < 0) + { + 1 + } else { + 0 + }; + + Self { + header: FullBoxHeader::new(Self::NAME, version, flags), + data_offset: Some(0), + first_sample_flags, + samples, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TrunSample { + pub duration: Option, + pub size: Option, + pub flags: Option, + pub composition_time_offset: Option, // we use i64 because it is either a u32 or a i32 +} + +#[derive(Debug, Clone, PartialEq, Copy, Default)] +pub struct TrunSampleFlag { + pub reserved: u8, // 4 bits + pub is_leading: u8, // 2 bits + pub sample_depends_on: u8, // 2 bits + pub sample_is_depended_on: u8, // 2 bits + pub sample_has_redundancy: u8, // 2 bits + pub sample_padding_value: u8, // 3 bits + pub sample_is_non_sync_sample: bool, // 1 bit + pub sample_degradation_priority: u16, // 16 bits +} + +impl TrunSampleFlag { + pub fn validate(&self) -> io::Result<()> { + if self.reserved != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "reserved bits must be 0", + )); + } + + if self.is_leading > 2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "is_leading must be 0, 1 or 2", + )); + } + + if self.sample_depends_on > 2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sample_depends_on must be 0, 1 or 2", + )); + } + + if self.sample_is_depended_on > 2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sample_is_depended_on must be 0, 1 or 2", + )); + } + + if self.sample_has_redundancy > 2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sample_has_redundancy must be 0, 1 or 2", + )); + } + + if self.sample_padding_value > 7 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sample_padding_value must be 0, 1, 2, 3, 4, 5, 6 or 7", + )); + } + + Ok(()) + } +} + +impl From for TrunSampleFlag { + fn from(value: u32) -> Self { + let reserved = (value >> 28) as u8; + let is_leading = ((value >> 26) & 0b11) as u8; + let sample_depends_on = ((value >> 24) & 0b11) as u8; + let sample_is_depended_on = ((value >> 22) & 0b11) as u8; + let sample_has_redundancy = ((value >> 20) & 0b11) as u8; + let sample_padding_value = ((value >> 17) & 0b111) as u8; + let sample_is_non_sync_sample = ((value >> 16) & 0b1) != 0; + let sample_degradation_priority = (value & 0xFFFF) as u16; + + Self { + reserved, + is_leading, + sample_depends_on, + sample_is_depended_on, + sample_has_redundancy, + sample_padding_value, + sample_is_non_sync_sample, + sample_degradation_priority, + } + } +} + +impl From for u32 { + fn from(value: TrunSampleFlag) -> Self { + let mut result = 0; + + result |= (value.reserved as u32) << 28; + result |= (value.is_leading as u32) << 26; + result |= (value.sample_depends_on as u32) << 24; + result |= (value.sample_is_depended_on as u32) << 22; + result |= (value.sample_has_redundancy as u32) << 20; + result |= (value.sample_padding_value as u32) << 17; + result |= (value.sample_is_non_sync_sample as u32) << 16; + result |= value.sample_degradation_priority as u32; + + result + } +} + +impl Trun { + pub const FLAG_DATA_OFFSET: u32 = 0x000001; + pub const FLAG_FIRST_SAMPLE_FLAGS: u32 = 0x000004; + pub const FLAG_SAMPLE_DURATION: u32 = 0x000100; + pub const FLAG_SAMPLE_SIZE: u32 = 0x000200; + pub const FLAG_SAMPLE_FLAGS: u32 = 0x000400; + pub const FLAG_SAMPLE_COMPOSITION_TIME_OFFSET: u32 = 0x000800; +} + +impl BoxType for Trun { + const NAME: [u8; 4] = *b"trun"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let sample_count = reader.read_u32::()?; + + let data_offset = if header.flags & Self::FLAG_DATA_OFFSET != 0 { + Some(reader.read_i32::()?) + } else { + None + }; + + let first_sample_flags = if header.flags & Self::FLAG_FIRST_SAMPLE_FLAGS != 0 { + Some(reader.read_u32::()?.into()) + } else { + None + }; + + let mut samples = Vec::with_capacity(sample_count as usize); + for _ in 0..sample_count { + let duration = if header.flags & Self::FLAG_SAMPLE_DURATION != 0 { + Some(reader.read_u32::()?) + } else { + None + }; + + let size = if header.flags & Self::FLAG_SAMPLE_SIZE != 0 { + Some(reader.read_u32::()?) + } else { + None + }; + + let flags = if header.flags & Self::FLAG_SAMPLE_FLAGS != 0 { + Some(reader.read_u32::()?.into()) + } else { + None + }; + + let composition_time_offset = + if header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET != 0 { + if header.version == 1 { + Some(reader.read_i32::()? as i64) + } else { + Some(reader.read_u32::()? as i64) + } + } else { + None + }; + + samples.push(TrunSample { + duration, + size, + flags, + composition_time_offset, + }); + } + + Ok(Self { + header, + data_offset, + first_sample_flags, + samples, + }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + 4 // sample_count + + if self.header.flags & Self::FLAG_DATA_OFFSET != 0 { 4 } else { 0 } + + if self.header.flags & Self::FLAG_FIRST_SAMPLE_FLAGS != 0 { 4 } else { 0 } + + self.samples.iter().map(|_| { + (if self.header.flags & Self::FLAG_SAMPLE_DURATION != 0 { 4 } else { 0 }) + + (if self.header.flags & Self::FLAG_SAMPLE_SIZE != 0 { 4 } else { 0 }) + + (if self.header.flags & Self::FLAG_SAMPLE_FLAGS != 0 { 4 } else { 0 }) + + (if self.header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET != 0 { + 4 + } else { + 0 + }) + }).sum::() + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u32::(self.samples.len() as u32)?; + + if let Some(data_offset) = self.data_offset { + writer.write_i32::(data_offset)?; + } + + if let Some(first_sample_flags) = self.first_sample_flags { + writer.write_u32::(first_sample_flags.into())?; + } + + for sample in &self.samples { + if let Some(duration) = sample.duration { + writer.write_u32::(duration)?; + } + + if let Some(size) = sample.size { + writer.write_u32::(size)?; + } + + if let Some(flags) = sample.flags { + writer.write_u32::(flags.into())?; + } + + if let Some(composition_time_offset) = sample.composition_time_offset { + if self.header.version == 1 { + writer.write_i32::(composition_time_offset as i32)?; + } else { + writer.write_u32::(composition_time_offset as u32)?; + } + } + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version > 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun version must be 0 or 1", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET == 0 + && self.header.version == 1 + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun version must be 0 if sample composition time offset is not present", + )); + } + + if self.header.flags & Self::FLAG_DATA_OFFSET != 0 && self.data_offset.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun data offset is present but not set", + )); + } else if self.header.flags & Self::FLAG_DATA_OFFSET == 0 && self.data_offset.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun data offset is not present but set", + )); + } + + if self.header.flags & Self::FLAG_FIRST_SAMPLE_FLAGS != 0 + && self.first_sample_flags.is_none() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun first sample flags is present but not set", + )); + } else if self.header.flags & Self::FLAG_FIRST_SAMPLE_FLAGS == 0 + && self.first_sample_flags.is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun first sample flags is not present but set", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_DURATION != 0 + && self.samples.iter().any(|s| s.duration.is_none()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample duration is present but not set", + )); + } else if self.header.flags & Self::FLAG_SAMPLE_DURATION == 0 + && self.samples.iter().any(|s| s.duration.is_some()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample duration is not present but set", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_SIZE != 0 + && self.samples.iter().any(|s| s.size.is_none()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample size is present but not set", + )); + } else if self.header.flags & Self::FLAG_SAMPLE_SIZE == 0 + && self.samples.iter().any(|s| s.size.is_some()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample size is not present but set", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_FLAGS != 0 + && self.samples.iter().any(|s| s.flags.is_none()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample flags is present but not set", + )); + } else if self.header.flags & Self::FLAG_SAMPLE_FLAGS == 0 + && self.samples.iter().any(|s| s.flags.is_some()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample flags is not present but set", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET != 0 + && self + .samples + .iter() + .any(|s| s.composition_time_offset.is_none()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample composition time offset is present but not set", + )); + } else if self.header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET == 0 + && self + .samples + .iter() + .any(|s| s.composition_time_offset.is_some()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample composition time offset is not present but set", + )); + } + + if self.header.flags & Self::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET != 0 { + if self.header.version == 1 + && self + .samples + .iter() + .any(|s| s.composition_time_offset.unwrap() > i32::MAX as i64) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample composition time offset cannot be larger than i32::MAX", + )); + } else if self.header.version == 0 + && self + .samples + .iter() + .any(|s| s.composition_time_offset.unwrap() > u32::MAX as i64) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "trun sample composition time offset cannot be larger than u32::MAX", + )); + } + } + + if let Some(first_sample_flags) = self.first_sample_flags { + first_sample_flags.validate()?; + } + + for sample in &self.samples { + if let Some(flags) = sample.flags { + flags.validate()?; + } + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/url.rs b/video/container/mp4/src/boxes/types/url.rs new file mode 100644 index 00000000..e98274c3 --- /dev/null +++ b/video/container/mp4/src/boxes/types/url.rs @@ -0,0 +1,102 @@ +use std::io; + +use byteorder::{ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +#[derive(Debug, Clone, PartialEq)] +/// Url Box +/// ISO/IEC 14496-12:2022(E) - 8.7.2.2 +pub struct Url { + pub header: FullBoxHeader, + pub location: Option, +} + +impl Default for Url { + fn default() -> Self { + Self::new() + } +} + +impl Url { + pub fn new() -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 1), + location: None, + } + } +} + +impl BoxType for Url { + const NAME: [u8; 4] = *b"url "; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let location = if header.flags == 0 { + let mut location = String::new(); + loop { + let byte = reader.read_u8()?; + if byte == 0 { + break; + } + location.push(byte as char); + } + + Some(location) + } else { + None + }; + + Ok(Self { header, location }) + } + + fn primitive_size(&self) -> u64 { + self.header.size() + + if let Some(location) = &self.location { + location.len() as u64 + 1 // null terminator + } else { + 0 + } + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + if let Some(location) = &self.location { + writer.write_all(location.as_bytes())?; + writer.write_u8(0)?; + } + + Ok(()) + } + + fn validate(&self) -> io::Result<()> { + if self.header.version != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "url version must be 0", + )); + } + + if self.header.flags != 0 && self.location.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "url location must be empty if flags is 1", + )); + } else if self.header.flags == 0 && self.location.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "url location must be present if flags is 0", + )); + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/boxes/types/vmhd.rs b/video/container/mp4/src/boxes/types/vmhd.rs new file mode 100644 index 00000000..13e4df8e --- /dev/null +++ b/video/container/mp4/src/boxes/types/vmhd.rs @@ -0,0 +1,78 @@ +use std::io; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; + +use crate::boxes::{ + header::{BoxHeader, FullBoxHeader}, + traits::BoxType, +}; + +const OP_COLOR_SIZE: usize = 3; + +#[derive(Debug, Clone, PartialEq)] +/// Video Media Header Box +/// ISO/IEC 14496-12:2022(E) - 12.1.2 +pub struct Vmhd { + pub header: FullBoxHeader, + + pub graphics_mode: u16, + pub opcolor: [u16; OP_COLOR_SIZE], +} + +impl Default for Vmhd { + fn default() -> Self { + Self::new() + } +} + +impl Vmhd { + pub fn new() -> Self { + Self { + header: FullBoxHeader::new(Self::NAME, 0, 1), + graphics_mode: 0, + opcolor: [0; OP_COLOR_SIZE], + } + } +} + +impl BoxType for Vmhd { + const NAME: [u8; 4] = *b"vmhd"; + + fn demux(header: BoxHeader, data: Bytes) -> io::Result { + let mut reader = io::Cursor::new(data); + + let header = FullBoxHeader::demux(header, &mut reader)?; + + let graphics_mode = reader.read_u16::()?; + + let mut opcolor = [0; OP_COLOR_SIZE]; + for v in opcolor.iter_mut() { + *v = reader.read_u16::()?; + } + + Ok(Self { + header, + graphics_mode, + opcolor, + }) + } + + fn primitive_size(&self) -> u64 { + let size = self.header.size(); + let size = size + 2; // graphics_mode + size + 2 * OP_COLOR_SIZE as u64 // opcolor + } + + fn primitive_mux(&self, writer: &mut T) -> io::Result<()> { + self.header.mux(writer)?; + + writer.write_u16::(self.graphics_mode)?; + + for i in 0..OP_COLOR_SIZE { + writer.write_u16::(self.opcolor[i])?; + } + + Ok(()) + } +} diff --git a/video/container/mp4/src/codec.rs b/video/container/mp4/src/codec.rs new file mode 100644 index 00000000..1f9a8ee2 --- /dev/null +++ b/video/container/mp4/src/codec.rs @@ -0,0 +1,114 @@ +use std::fmt; + +use aac::AudioObjectType; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoCodec { + /// https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter + Avc { + profile: u8, + constraint_set: u8, + level: u8, + }, + /// There is barely any documentation on this. + /// http://hevcvideo.xp3.biz/html5_video.html + Hevc { + general_profile_space: u8, + profile_compatibility: u32, + profile: u8, + level: u8, + tier: bool, + constraint_indicator: u64, + }, + /// https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter#av1 + Av1 { + profile: u8, + level: u8, + tier: bool, + depth: u8, + monochrome: bool, + sub_sampling_x: bool, + sub_sampling_y: bool, + color_primaries: u8, + transfer_characteristics: u8, + matrix_coefficients: u8, + full_range_flag: bool, + }, +} + +impl fmt::Display for VideoCodec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VideoCodec::Avc { + profile, + constraint_set, + level, + } => write!(f, "avc1.{:02x}{:02x}{:02x}", profile, constraint_set, level), + VideoCodec::Hevc { + general_profile_space, + profile, + level, + tier, + profile_compatibility, + constraint_indicator, + } => write!( + f, + "hev1.{}{:x}.{:x}.{}{:x}.{:x}", + match general_profile_space { + 1 => "A", + 2 => "B", + 3 => "C", + _ => "", + }, + profile, // 1 or 2 chars (hex) + profile_compatibility, + if *tier { 'H' } else { 'L' }, + level, // 1 or 2 chars (hex) + constraint_indicator, + ), + VideoCodec::Av1 { + profile, + level, + tier, + depth, + monochrome, + sub_sampling_x, + sub_sampling_y, + color_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + } => write!( + f, + "av01.{}.{}{}.{:02}.{}.{}{}{}.{:02}.{:02}.{:02}.{}", + profile, + level, + if *tier { 'H' } else { 'M' }, + depth, + if *monochrome { 1 } else { 0 }, + if *sub_sampling_x { 1 } else { 0 }, + if *sub_sampling_y { 1 } else { 0 }, + if *monochrome { 1 } else { 0 }, + color_primaries, + transfer_characteristics, + matrix_coefficients, + if *full_range_flag { 1 } else { 0 }, + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioCodec { + Aac { object_type: AudioObjectType }, + Opus, +} + +impl fmt::Display for AudioCodec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AudioCodec::Aac { object_type } => write!(f, "mp4a.40.{}", u16::from(*object_type)), + AudioCodec::Opus => write!(f, "opus"), + } + } +} diff --git a/video/container/mp4/src/lib.rs b/video/container/mp4/src/lib.rs new file mode 100644 index 00000000..4fa9a0b4 --- /dev/null +++ b/video/container/mp4/src/lib.rs @@ -0,0 +1,8 @@ +mod boxes; + +pub mod codec; + +pub use boxes::{header, types, BoxType, DynBox}; + +#[cfg(test)] +mod tests; diff --git a/video/container/mp4/src/tests/demux.rs b/video/container/mp4/src/tests/demux.rs new file mode 100644 index 00000000..acf79213 --- /dev/null +++ b/video/container/mp4/src/tests/demux.rs @@ -0,0 +1,2574 @@ +use std::{ + io::{self, Write}, + path::PathBuf, + process::{Command, Stdio}, +}; + +use av1::AV1CodecConfigurationRecord; +use bytes::{Buf, Bytes}; +use fixed::FixedI32; +use h264::AVCDecoderConfigurationRecord; +use h265::{HEVCDecoderConfigurationRecord, NaluArray, NaluType}; + +use crate::{ + boxes::{ + header::{BoxHeader, FullBoxHeader}, + types::{ + avc1::Avc1, + avcc::AvcC, + btrt::Btrt, + dinf::Dinf, + dref::Dref, + edts::Edts, + elst::{Elst, ElstEntry}, + esds::{ + descriptor::{ + header::DescriptorHeader, + types::{ + decoder_config::DecoderConfigDescriptor, + decoder_specific_info::DecoderSpecificInfoDescriptor, es::EsDescriptor, + sl_config::SLConfigDescriptor, + }, + }, + Esds, + }, + ftyp::{FourCC, Ftyp}, + hdlr::{HandlerType, Hdlr}, + mdhd::Mdhd, + mfhd::Mfhd, + minf::Minf, + moof::Moof, + mp4a::Mp4a, + mvhd::Mvhd, + pasp::Pasp, + smhd::Smhd, + stbl::Stbl, + stco::Stco, + stsc::Stsc, + stsd::{AudioSampleEntry, SampleEntry, Stsd, VisualSampleEntry}, + stsz::Stsz, + stts::Stts, + tfdt::Tfdt, + tfhd::Tfhd, + tkhd::Tkhd, + traf::Traf, + trun::{Trun, TrunSample}, + url::Url, + vmhd::Vmhd, + }, + DynBox, + }, + types::{ + av01::Av01, + av1c::Av1C, + colr::{ColorType, Colr}, + hev1::Hev1, + hvcc::HvcC, + mvex::Mvex, + trex::Trex, + }, +}; + +#[test] +fn test_demux_avc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = std::fs::read(dir.join("avc_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + // The values we assert against are taken from `https://mlynoteka.mlyn.org/mp4parser/` + // We assume this to be a correct implementation of the MP4 format. + + // ftyp + { + let ftyp = boxes[0].as_ftyp().expect("ftyp"); + assert_eq!( + ftyp, + &Ftyp { + header: BoxHeader { box_type: *b"ftyp" }, + major_brand: FourCC::Iso5, + minor_version: 512, + compatible_brands: vec![FourCC::Iso5, FourCC::Iso6, FourCC::Mp41], + } + ); + } + + // moov + { + let moov = boxes[1].as_moov().expect("moov"); + assert_eq!( + moov.mvhd, + Mvhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mvhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 1000, + duration: 0, + rate: FixedI32::from_num(1), + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + next_track_id: 2, + reserved: 0, + pre_defined: [0; 6], + reserved2: [0; 2], + } + ); + + // video track + { + let video_trak = &moov.traks[0]; + assert_eq!( + video_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 1, + reserved: 0, + duration: 0, + reserved2: [0; 2], + layer: 0, + alternate_group: 0, + volume: 0.into(), + reserved3: 0, + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_num(3840), + height: FixedI32::from_num(2160), + } + ); + + assert_eq!( + video_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ + ElstEntry { + segment_duration: 33, + media_time: -1, + media_rate_integer: 1, + media_rate_fraction: 0, + }, + ElstEntry { + segment_duration: 0, + media_time: 2000, + media_rate_integer: 1, + media_rate_fraction: 0, + }, + ], + }), + unknown: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 60000, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: HandlerType::Vide, + reserved: [0; 3], + name: "GPAC ISO Video Handler".into(), + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.minf.vmhd, + Some(Vmhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"vmhd" }, + version: 0, + flags: 1, + }, + graphics_mode: 0, + opcolor: [0, 0, 0], + }) + ); + + assert_eq!( + video_trak.mdia.minf.dinf, + Dinf { + header: BoxHeader { box_type: *b"dinf" }, + unknown: vec![], + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsd, + Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Avc1(Avc1 { + header: BoxHeader { box_type: *b"avc1" }, + visual_sample_entry: SampleEntry:: { + reserved: [0; 6], + data_reference_index: 1, + extension: VisualSampleEntry { + clap: None, + colr: None, + compressorname: [0; 32], + depth: 24, + frame_count: 1, + width: 3840, + height: 2160, + horizresolution: 4718592, + vertresolution: 4718592, + pre_defined2: [0; 3], + pre_defined: 0, + pre_defined3: -1, + reserved: 0, + reserved2: 0, + pasp: Some(Pasp { + header: BoxHeader { box_type: *b"pasp" }, + h_spacing: 1, + v_spacing: 1, + }), + } + }, + avcc: AvcC { + header: BoxHeader { box_type: *b"avcC" }, + avc_decoder_configuration_record: AVCDecoderConfigurationRecord { + level_indication: 51, + profile_indication: 100, + configuration_version: 1, + length_size_minus_one: 3, + profile_compatibility: 0, + extended_config: None, + sps: vec![Bytes::from(vec![ + 103, 100, 0, 51, 172, 202, 80, 15, 0, 16, 251, 1, 16, 0, 0, 3, + 0, 16, 0, 0, 7, 136, 241, 131, 25, 96 + ])], + pps: vec![Bytes::from(vec![104, 233, 59, 44, 139])], + }, + }, + btrt: Some(Btrt { + header: BoxHeader { box_type: *b"btrt" }, + avg_bitrate: 8002648, + max_bitrate: 8002648, + buffer_size_db: 0, + }), + unknown: vec![], + })], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stts, + Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsc, + Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsz, + Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stco, + Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!(video_trak.mdia.minf.stbl.co64, None); + + assert_eq!(video_trak.mdia.minf.stbl.ctts, None); + + assert_eq!(video_trak.mdia.minf.stbl.padb, None); + + assert_eq!(video_trak.mdia.minf.stbl.sbgp, None); + + assert_eq!(video_trak.mdia.minf.stbl.sdtp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stdp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stsh, None); + + assert_eq!(video_trak.mdia.minf.stbl.stss, None); + + assert_eq!(video_trak.mdia.minf.stbl.stz2, None); + + assert_eq!(video_trak.mdia.minf.stbl.subs, None); + } + + // audio track + { + let audio_trak = &moov.traks[1]; + assert_eq!( + audio_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 2, + duration: 0, + layer: 0, + alternate_group: 1, + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_bits(0), + height: FixedI32::from_bits(0), + reserved: 0, + reserved2: [0; 2], + reserved3: 0, + } + ); + + assert_eq!( + audio_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ElstEntry { + media_rate_fraction: 0, + media_time: 1024, + media_rate_integer: 1, + segment_duration: 0, + }], + }), + unknown: vec![], + }) + ); + + assert_eq!( + audio_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 48000, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + audio_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: (*b"soun").into(), + name: "GPAC ISO Audio Handler".to_string(), + pre_defined: 0, + reserved: [0; 3], + } + ); + + assert_eq!( + audio_trak.mdia.minf, + Minf { + header: BoxHeader { box_type: *b"minf" }, + smhd: Some(Smhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"smhd" }, + version: 0, + flags: 0, + }, + balance: 0.into(), + reserved: 0, + }), + dinf: Dinf { + header: BoxHeader { box_type: *b"dinf" }, + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + unknown: vec![], + }, + stbl: Stbl { + header: BoxHeader { box_type: *b"stbl" }, + stsd: Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Mp4a(Mp4a { + header: BoxHeader { box_type: *b"mp4a" }, + audio_sample_entry: SampleEntry:: { + data_reference_index: 1, + reserved: [0; 6], + extension: AudioSampleEntry { + channel_count: 2, + pre_defined: 0, + reserved2: 0, + sample_size: 16, + reserved: [0; 2], + sample_rate: 48000, + }, + }, + esds: Esds { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"esds" }, + version: 0, + flags: 0, + }, + es_descriptor: EsDescriptor { + header: DescriptorHeader { tag: 3.into() }, + es_id: 2, + depends_on_es_id: None, + ocr_es_id: None, + stream_priority: 0, + url: None, + decoder_config: Some(DecoderConfigDescriptor { + header: DescriptorHeader { tag: 4.into() }, + avg_bitrate: 128000, + buffer_size_db: 0, + max_bitrate: 128000, + reserved: 1, + stream_type: 5, + object_type_indication: 64, + up_stream: false, + decoder_specific_info: Some( + DecoderSpecificInfoDescriptor { + header: DescriptorHeader { tag: 5.into() }, + data: Bytes::from_static(b"\x11\x90V\xe5\0"), + } + ), + unknown: vec![], + }), + sl_config: Some(SLConfigDescriptor { + header: DescriptorHeader { tag: 6.into() }, + predefined: 2, + data: Bytes::new(), + }), + unknown: vec![], + }, + unknown: vec![], + }, + btrt: Some(Btrt { + header: BoxHeader { box_type: *b"btrt" }, + buffer_size_db: 0, + max_bitrate: 128000, + avg_bitrate: 128000, + }), + unknown: vec![], + })], + }, + co64: None, + ctts: None, + padb: None, + sbgp: None, + sdtp: None, + stdp: None, + stco: Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsc: Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsh: None, + stss: None, + stsz: Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }), + stts: Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stz2: None, + subs: None, + unknown: vec![], + }, + hmhd: None, + nmhd: None, + vmhd: None, + unknown: vec![], + } + ); + } + + // mvex + assert_eq!( + moov.mvex, + Some(Mvex { + header: BoxHeader { box_type: *b"mvex" }, + mehd: None, + trex: vec![ + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 1, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 2, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + ], + unknown: vec![], + }) + ) + } + + // moof + { + let moof = boxes[2].as_moof().expect("moof"); + assert_eq!( + moof, + &Moof { + header: BoxHeader { box_type: *b"moof" }, + mfhd: Mfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mfhd" }, + version: 0, + flags: 0, + }, + sequence_number: 1, + }, + traf: vec![ + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 1, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(1000), + default_sample_size: Some(2232), + default_sample_flags: Some(0x1010000.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET + | Trun::FLAG_FIRST_SAMPLE_FLAGS + | Trun::FLAG_DATA_OFFSET + | Trun::FLAG_SAMPLE_SIZE, + }, + data_offset: Some(356), + first_sample_flags: Some(33554432.into()), + samples: vec![ + TrunSample { + composition_time_offset: Some(2000), + duration: None, + flags: None, + size: Some(2232), + }, + TrunSample { + composition_time_offset: Some(2000), + duration: None, + flags: None, + size: Some(238), + }, + TrunSample { + composition_time_offset: Some(2000), + duration: None, + flags: None, + size: Some(19348), + }, + TrunSample { + composition_time_offset: Some(2000), + duration: None, + flags: None, + size: Some(12481), + }, + TrunSample { + composition_time_offset: Some(7000), + duration: None, + flags: None, + size: Some(30195), + }, + TrunSample { + composition_time_offset: Some(3000), + duration: None, + flags: None, + size: Some(2200), + }, + TrunSample { + composition_time_offset: Some(0), + duration: None, + flags: None, + size: Some(855), + }, + TrunSample { + composition_time_offset: Some(0), + duration: None, + flags: None, + size: Some(820), + }, + TrunSample { + composition_time_offset: Some(1000), + duration: None, + flags: None, + size: Some(991), + }, + TrunSample { + composition_time_offset: Some(1000), + duration: None, + flags: None, + size: Some(747), + }, + TrunSample { + composition_time_offset: Some(2000), + duration: None, + flags: None, + size: Some(9309), + }, + TrunSample { + composition_time_offset: Some(3000), + duration: None, + flags: None, + size: Some(2475), + }, + TrunSample { + composition_time_offset: Some(1000), + duration: None, + flags: None, + size: Some(1001), + }, + TrunSample { + composition_time_offset: Some(3000), + duration: None, + flags: None, + size: Some(3175), + }, + TrunSample { + composition_time_offset: Some(1000), + duration: None, + flags: None, + size: Some(698), + }, + ], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 2, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(1024), + default_sample_size: Some(24), + default_sample_flags: Some(0x2000000.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_DATA_OFFSET | Trun::FLAG_SAMPLE_SIZE, + }, + data_offset: Some(87121), + first_sample_flags: None, + samples: vec![ + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(24), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(8), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(303), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(630), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(619), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(621), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(631), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(624), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(610), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(446), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(419), + }, + TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: Some(417), + }, + ], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + ], + unknown: vec![], + } + ); + } + + // mdat + { + let mdat = boxes[3].as_mdat().expect("mdat"); + assert_eq!(mdat.data.len(), 1); + assert_eq!(mdat.data[0].len(), 92125 - 8); // 8 is mdat header size + } +} + +#[test] +fn test_mux_avc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + let data = std::fs::read(dir.join("avc_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + let mut writer = io::Cursor::new(Vec::new()); + for box_ in &boxes { + box_.mux(&mut writer).unwrap(); + } + + let data = Bytes::from(writer.into_inner()); + let mut reader = io::Cursor::new(data.clone()); + + let mut new_boxes = Vec::new(); + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + new_boxes.push(box_); + } + + assert_eq!(boxes, new_boxes); + + // Pipe into ffprobe to check the output is valid. + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + ffprobe + .stdin + .as_mut() + .unwrap() + .write_all(&data) + .expect("write to stdin"); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["nb_streams"], 2); + assert_eq!(json["format"]["probe_score"], 100); + + assert_eq!(json["streams"][0]["codec_name"], "h264"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["avg_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["r_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["height"], 2160); + assert_eq!(json["streams"][0]["width"], 3840); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "48000"); + assert_eq!(json["streams"][1]["channels"], 2); +} + +#[test] +fn test_demux_av1_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = std::fs::read(dir.join("av1_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + // The values we assert against are taken from `https://mlynoteka.mlyn.org/mp4parser/` + // We assume this to be a correct implementation of the MP4 format. + + // ftyp + { + let ftyp = boxes[0].as_ftyp().expect("ftyp"); + assert_eq!( + ftyp, + &Ftyp { + header: BoxHeader { box_type: *b"ftyp" }, + major_brand: FourCC::Iso5, + minor_version: 512, + compatible_brands: vec![FourCC::Iso5, FourCC::Iso6, FourCC::Av01, FourCC::Mp41], + } + ); + } + + // moov + { + let moov = boxes[1].as_moov().expect("moov"); + assert_eq!( + moov.mvhd, + Mvhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mvhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 1000, + duration: 0, + rate: FixedI32::from_num(1), + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + next_track_id: 2, + reserved: 0, + pre_defined: [0; 6], + reserved2: [0; 2], + } + ); + + // video track + { + let video_trak = &moov.traks[0]; + assert_eq!( + video_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 1, + reserved: 0, + duration: 0, + reserved2: [0; 2], + layer: 0, + alternate_group: 0, + volume: 0.into(), + reserved3: 0, + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_num(480), + height: FixedI32::from_num(852), + } + ); + + assert_eq!( + video_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ElstEntry { + segment_duration: 0, + media_time: 0, + media_rate_integer: 1, + media_rate_fraction: 0, + },], + }), + unknown: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 15360, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: HandlerType::Vide, + reserved: [0; 3], + name: "Core Media Video".into(), + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.minf.vmhd, + Some(Vmhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"vmhd" }, + version: 0, + flags: 1, + }, + graphics_mode: 0, + opcolor: [0, 0, 0], + }) + ); + + assert_eq!( + video_trak.mdia.minf.dinf, + Dinf { + header: BoxHeader { box_type: *b"dinf" }, + unknown: vec![], + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsd, + Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Av01(Av01 { + header: BoxHeader { box_type: *b"av01" }, + visual_sample_entry: SampleEntry:: { + reserved: [0; 6], + data_reference_index: 1, + extension: VisualSampleEntry { + clap: None, + colr: Some(Colr { + header: BoxHeader { box_type: *b"colr" }, + color_type: ColorType::Nclx { + color_primaries: 1, + matrix_coefficients: 1, + transfer_characteristics: 1, + full_range_flag: false, + }, + }), + compressorname: [ + 22, 76, 97, 118, 99, 54, 48, 46, 57, 46, 49, 48, 48, 32, 108, + 105, 98, 115, 118, 116, 97, 118, 49, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ], + depth: 24, + frame_count: 1, + width: 480, + height: 852, + horizresolution: 4718592, + vertresolution: 4718592, + pre_defined2: [0; 3], + pre_defined: 0, + pre_defined3: -1, + reserved: 0, + reserved2: 0, + pasp: None, + } + }, + av1c: Av1C { + header: BoxHeader { box_type: *b"av1C" }, + av1_config: AV1CodecConfigurationRecord { + marker: true, + version: 1, + seq_profile: 0, + seq_level_idx_0: 4, + seq_tier_0: false, + high_bitdepth: false, + twelve_bit: false, + monochrome: false, + chroma_subsampling_x: true, + chroma_subsampling_y: true, + chroma_sample_position: 1, + initial_presentation_delay_minus_one: None, + config_obu: b"\n\x0e\0\0\0$O\x7fS\0\xbe\x04\x04\x04\x04\x90" + .to_vec() + .into(), + }, + }, + btrt: None, + /// This box is not defined in the spec, so we are not parsing it. + unknown: vec![DynBox::Unknown(( + BoxHeader { box_type: *b"fiel" }, + b"\x01\0".to_vec().into() + ))], + })], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stts, + Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsc, + Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsz, + Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stco, + Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!(video_trak.mdia.minf.stbl.co64, None); + + assert_eq!(video_trak.mdia.minf.stbl.ctts, None); + + assert_eq!(video_trak.mdia.minf.stbl.padb, None); + + assert_eq!(video_trak.mdia.minf.stbl.sbgp, None); + + assert_eq!(video_trak.mdia.minf.stbl.sdtp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stdp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stsh, None); + + assert_eq!(video_trak.mdia.minf.stbl.stss, None); + + assert_eq!(video_trak.mdia.minf.stbl.stz2, None); + + assert_eq!(video_trak.mdia.minf.stbl.subs, None); + } + + // audio track + { + let audio_trak = &moov.traks[1]; + assert_eq!( + audio_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 2, + duration: 0, + layer: 0, + alternate_group: 1, + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_bits(0), + height: FixedI32::from_bits(0), + reserved: 0, + reserved2: [0; 2], + reserved3: 0, + } + ); + + assert_eq!( + audio_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ElstEntry { + media_rate_fraction: 0, + media_time: 1024, + media_rate_integer: 1, + segment_duration: 0, + }], + }), + unknown: vec![], + }) + ); + + assert_eq!( + audio_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 44100, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + audio_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: (*b"soun").into(), + name: "Core Media Audio".to_string(), + pre_defined: 0, + reserved: [0; 3], + } + ); + + assert_eq!( + audio_trak.mdia.minf, + Minf { + header: BoxHeader { box_type: *b"minf" }, + smhd: Some(Smhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"smhd" }, + version: 0, + flags: 0, + }, + balance: 0.into(), + reserved: 0, + }), + dinf: Dinf { + header: BoxHeader { box_type: *b"dinf" }, + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + unknown: vec![], + }, + stbl: Stbl { + header: BoxHeader { box_type: *b"stbl" }, + stsd: Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Mp4a(Mp4a { + header: BoxHeader { box_type: *b"mp4a" }, + audio_sample_entry: SampleEntry:: { + data_reference_index: 1, + reserved: [0; 6], + extension: AudioSampleEntry { + channel_count: 1, + pre_defined: 0, + reserved2: 0, + sample_size: 16, + reserved: [0; 2], + sample_rate: 44100, + }, + }, + esds: Esds { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"esds" }, + version: 0, + flags: 0, + }, + es_descriptor: EsDescriptor { + header: DescriptorHeader { tag: 3.into() }, + es_id: 2, + depends_on_es_id: None, + ocr_es_id: None, + stream_priority: 0, + url: None, + decoder_config: Some(DecoderConfigDescriptor { + header: DescriptorHeader { tag: 4.into() }, + avg_bitrate: 69000, + buffer_size_db: 0, + max_bitrate: 69000, + reserved: 1, + stream_type: 5, + object_type_indication: 64, + up_stream: false, + decoder_specific_info: Some( + DecoderSpecificInfoDescriptor { + header: DescriptorHeader { tag: 5.into() }, + data: Bytes::from_static(b"\x12\x08V\xe5\0"), + } + ), + unknown: vec![], + }), + sl_config: Some(SLConfigDescriptor { + header: DescriptorHeader { tag: 6.into() }, + predefined: 2, + data: Bytes::new(), + }), + unknown: vec![], + }, + unknown: vec![], + }, + btrt: Some(Btrt { + header: BoxHeader { box_type: *b"btrt" }, + buffer_size_db: 0, + max_bitrate: 69000, + avg_bitrate: 69000, + }), + unknown: vec![], + })], + }, + co64: None, + ctts: None, + padb: None, + sbgp: None, + sdtp: None, + stdp: None, + stco: Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsc: Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsh: None, + stss: None, + stsz: Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }), + stts: Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stz2: None, + subs: None, + unknown: vec![], + }, + hmhd: None, + nmhd: None, + vmhd: None, + unknown: vec![], + } + ); + } + + // mvex + assert_eq!( + moov.mvex, + Some(Mvex { + header: BoxHeader { box_type: *b"mvex" }, + mehd: None, + trex: vec![ + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 1, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 2, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + ], + unknown: vec![], + }) + ) + } + + // moof + { + let moof = boxes[2].as_moof().expect("moof"); + assert_eq!( + moof, + &Moof { + header: BoxHeader { box_type: *b"moof" }, + mfhd: Mfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mfhd" }, + version: 0, + flags: 0, + }, + sequence_number: 1, + }, + traf: vec![ + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 1, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(512), + default_sample_size: Some(26336), + default_sample_flags: Some(16842752.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_FIRST_SAMPLE_FLAGS | Trun::FLAG_DATA_OFFSET + }, + data_offset: Some(188), + first_sample_flags: Some(33554432.into()), + samples: vec![TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: None, + },], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 2, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(1024), + default_sample_size: Some(234), + default_sample_flags: Some(0x2000000.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_DATA_OFFSET + }, + data_offset: Some(26524), + first_sample_flags: None, + samples: vec![TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: None, + },], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + ], + unknown: vec![], + } + ); + } + + // mdat + { + let mdat = boxes[3].as_mdat().expect("mdat"); + assert_eq!(mdat.data.len(), 1); + assert_eq!(mdat.data[0].len(), 26578 - 8); // 8 is mdat header size + } +} + +#[test] +fn test_demux_hevc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let data = std::fs::read(dir.join("hevc_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + // The values we assert against are taken from `https://mlynoteka.mlyn.org/mp4parser/` + // We assume this to be a correct implementation of the MP4 format. + + // ftyp + { + let ftyp = boxes[0].as_ftyp().expect("ftyp"); + assert_eq!( + ftyp, + &Ftyp { + header: BoxHeader { box_type: *b"ftyp" }, + major_brand: FourCC::Iso5, + minor_version: 512, + compatible_brands: vec![FourCC::Iso5, FourCC::Iso6, FourCC::Mp41], + } + ); + } + + // moov + { + let moov = boxes[1].as_moov().expect("moov"); + assert_eq!( + moov.mvhd, + Mvhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mvhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 1000, + duration: 0, + rate: FixedI32::from_num(1), + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + next_track_id: 2, + reserved: 0, + pre_defined: [0; 6], + reserved2: [0; 2], + } + ); + + // video track + { + let video_trak = &moov.traks[0]; + assert_eq!( + video_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 1, + reserved: 0, + duration: 0, + reserved2: [0; 2], + layer: 0, + alternate_group: 0, + volume: 0.into(), + reserved3: 0, + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_num(3840), + height: FixedI32::from_num(2160), + } + ); + + assert_eq!( + video_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ElstEntry { + segment_duration: 0, + media_time: 512, + media_rate_integer: 1, + media_rate_fraction: 0, + },], + }), + unknown: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 15360, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: HandlerType::Vide, + reserved: [0; 3], + name: "GPAC ISO Video Handler".into(), + pre_defined: 0, + } + ); + + assert_eq!( + video_trak.mdia.minf.vmhd, + Some(Vmhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"vmhd" }, + version: 0, + flags: 1, + }, + graphics_mode: 0, + opcolor: [0, 0, 0], + }) + ); + + assert_eq!( + video_trak.mdia.minf.dinf, + Dinf { + header: BoxHeader { box_type: *b"dinf" }, + unknown: vec![], + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsd, + Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![ + DynBox::Hev1(Hev1 { + header: BoxHeader { box_type: *b"hev1" }, + visual_sample_entry: SampleEntry { + reserved: [0, 0, 0, 0, 0, 0], + data_reference_index: 1, + extension: VisualSampleEntry { + pre_defined: 0, + reserved: 0, + pre_defined2: [0, 0, 0], + width: 3840, + height: 2160, + horizresolution: 4718592, + vertresolution: 4718592, + reserved2: 0, + frame_count: 1, + compressorname: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + depth: 24, + pre_defined3: -1, + clap: None, + colr: None, + pasp: Some(Pasp { + header: BoxHeader { box_type: *b"pasp" }, + h_spacing: 1, + v_spacing: 1 + }) + } + }, + hvcc: HvcC { + header: BoxHeader { box_type: *b"hvcC" }, + hevc_config: HEVCDecoderConfigurationRecord { + configuration_version: 1, + general_profile_space: 0, + general_tier_flag: false, + general_profile_idc: 1, + general_profile_compatibility_flags: 96, + general_constraint_indicator_flags: 144, + general_level_idc: 153, + min_spatial_segmentation_idc: 0, + parallelism_type: 0, + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + avg_frame_rate: 0, + constant_frame_rate: 0, + num_temporal_layers: 1, + temporal_id_nested: true, + length_size_minus_one: 3, + arrays: vec![ + NaluArray { + array_completeness: false, + nal_unit_type: NaluType::Vps, + nalus: vec![b"@\x01\x0c\x01\xff\xff\x01`\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\x95\x98\t".to_vec().into()] + }, + NaluArray { + array_completeness: false, + nal_unit_type: NaluType::Sps, + nalus: vec![b"B\x01\x01\x01`\0\0\x03\0\x90\0\0\x03\0\0\x03\0\x99\xa0\x01\xe0 \x02\x1cYef\x92L\xaf\x01h\x08\0\0\x03\0\x08\0\0\x03\x01\xe0@".to_vec().into()] + }, + NaluArray { + array_completeness: false, + nal_unit_type: NaluType::Pps, + nalus: vec![b"D\x01\xc1r\xb4\"@".to_vec().into()], + }, + NaluArray { + array_completeness: false, + nal_unit_type: NaluType::Unknown(39), + nalus: vec![b"N\x01\x05\xff\xff\xff\xff\xff\xff\xff\xff\xf5,\xa2\xde\t\xb5\x17G\xdb\xbbU\xa4\xfe\x7f\xc2\xfcNx265 (build 199) - 3.5+1-f0c1022b6:[Linux][GCC 11.2.0][64 bit] 8bit+10bit+12bit - H.265/HEVC codec - Copyright 2013-2018 (c) Multicoreware, Inc - http://x265.org - options: cpuid=1111039 frame-threads=6 no-wpp no-pmode no-pme no-psnr no-ssim log-level=2 bitdepth=8 input-csp=1 fps=60/1 input-res=3840x2160 interlace=0 total-frames=0 level-idc=0 high-tier=1 uhd-bd=0 ref=3 no-allow-non-conformance no-repeat-headers annexb no-aud no-hrd info hash=0 no-temporal-layers open-gop min-keyint=25 keyint=250 gop-lookahead=0 bframes=4 b-adapt=2 b-pyramid bframe-bias=0 rc-lookahead=20 lookahead-slices=0 scenecut=40 hist-scenecut=0 radl=0 no-splice no-intra-refresh ctu=64 min-cu-size=8 no-rect no-amp max-tu-size=32 tu-inter-depth=1 tu-intra-depth=1 limit-tu=0 rdoq-level=0 dynamic-rd=0.00 no-ssim-rd signhide no-tskip nr-intra=0 nr-inter=0 no-constrained-intra strong-intra-smoothing max-merge=3 limit-refs=1 no-limit-modes me=1 subme=2 merange=57 temporal-mvp no-frame-dup no-hme weightp no-weightb no-analyze-src-pics deblock=0:0 sao no-sao-non-deblock rd=3 selective-sao=4 early-skip rskip no-fast-intra no-tskip-fast no-cu-lossless b-intra no-splitrd-skip rdpenalty=0 psy-rd=2.00 psy-rdoq=0.00 no-rd-refine no-lossless cbqpoffs=0 crqpoffs=0 rc=crf crf=28.0 qcomp=0.60 qpstep=4 stats-write=0 stats-read=0 ipratio=1.40 pbratio=1.30 aq-mode=2 aq-strength=1.00 cutree zone-count=0 no-strict-cbr qg-size=32 no-rc-grain qpmax=69 qpmin=0 no-const-vbv sar=1 overscan=0 videoformat=5 range=0 colorprim=2 transfer=2 colormatrix=2 chromaloc=0 display-window=0 cll=0,0 min-luma=0 max-luma=255 log2-max-poc-lsb=8 vui-timing-info vui-hrd-info slices=1 no-opt-qp-pps no-opt-ref-list-length-pps no-multi-pass-opt-rps scenecut-bias=0.05 hist-threshold=0.03 no-opt-cu-delta-qp no-aq-motion no-hdr10 no-hdr10-opt no-dhdr10-opt no-idr-recovery-sei analysis-reuse-level=0 analysis-save-reuse-level=0 analysis-load-reuse-level=0 scale-factor=0 refine-intra=0 refine-inter=0 refine-mv=1 refine-ctu-distortion=0 no-limit-sao ctu-info=0 no-lowpass-dct refine-analysis-type=0 copy-pic=1 max-ausize-factor=1.0 no-dynamic-refine no-single-sei no-hevc-aq no-svt no-field qp-adaptation-range=1.00 scenecut-aware-qp=0conformance-window-offsets right=0 bottom=0 decoder-max-rate=0 no-vbv-live-multi-pass\x80".to_vec().into()] + } + ] + } + }, + btrt: None, + unknown: vec![ + DynBox::Unknown((BoxHeader { box_type: *b"fiel" }, b"\x01\0".to_vec().into())) + ], + }), + ], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stts, + Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsc, + Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stsz, + Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }) + ); + + assert_eq!( + video_trak.mdia.minf.stbl.stco, + Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + } + ); + + assert_eq!(video_trak.mdia.minf.stbl.co64, None); + + assert_eq!(video_trak.mdia.minf.stbl.ctts, None); + + assert_eq!(video_trak.mdia.minf.stbl.padb, None); + + assert_eq!(video_trak.mdia.minf.stbl.sbgp, None); + + assert_eq!(video_trak.mdia.minf.stbl.sdtp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stdp, None); + + assert_eq!(video_trak.mdia.minf.stbl.stsh, None); + + assert_eq!(video_trak.mdia.minf.stbl.stss, None); + + assert_eq!(video_trak.mdia.minf.stbl.stz2, None); + + assert_eq!(video_trak.mdia.minf.stbl.subs, None); + } + + // audio track + { + let audio_trak = &moov.traks[1]; + assert_eq!( + audio_trak.tkhd, + Tkhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tkhd" }, + version: 0, + flags: 3, + }, + creation_time: 0, + modification_time: 0, + track_id: 2, + duration: 0, + layer: 0, + alternate_group: 1, + volume: 1.into(), + matrix: [65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824], + width: FixedI32::from_bits(0), + height: FixedI32::from_bits(0), + reserved: 0, + reserved2: [0; 2], + reserved3: 0, + } + ); + + assert_eq!( + audio_trak.edts, + Some(Edts { + header: BoxHeader { box_type: *b"edts" }, + elst: Some(Elst { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"elst" }, + version: 0, + flags: 0, + }, + entries: vec![ElstEntry { + media_rate_fraction: 0, + media_time: 1024, + media_rate_integer: 1, + segment_duration: 0, + }], + }), + unknown: vec![], + }) + ); + + assert_eq!( + audio_trak.mdia.mdhd, + Mdhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mdhd" }, + version: 0, + flags: 0, + }, + creation_time: 0, + modification_time: 0, + timescale: 48000, + duration: 0, + language: 21956, + pre_defined: 0, + } + ); + + assert_eq!( + audio_trak.mdia.hdlr, + Hdlr { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"hdlr" }, + version: 0, + flags: 0, + }, + handler_type: (*b"soun").into(), + name: "GPAC ISO Audio Handler".to_string(), + pre_defined: 0, + reserved: [0; 3], + } + ); + + assert_eq!( + audio_trak.mdia.minf, + Minf { + header: BoxHeader { box_type: *b"minf" }, + smhd: Some(Smhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"smhd" }, + version: 0, + flags: 0, + }, + balance: 0.into(), + reserved: 0, + }), + dinf: Dinf { + header: BoxHeader { box_type: *b"dinf" }, + dref: Dref { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"dref" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Url(Url { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"url " }, + version: 0, + flags: 1, + }, + location: None, + })], + }, + unknown: vec![], + }, + stbl: Stbl { + header: BoxHeader { box_type: *b"stbl" }, + stsd: Stsd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsd" }, + version: 0, + flags: 0, + }, + entries: vec![DynBox::Mp4a(Mp4a { + header: BoxHeader { box_type: *b"mp4a" }, + audio_sample_entry: SampleEntry:: { + data_reference_index: 1, + reserved: [0; 6], + extension: AudioSampleEntry { + channel_count: 2, + pre_defined: 0, + reserved2: 0, + sample_size: 16, + reserved: [0; 2], + sample_rate: 48000, + }, + }, + esds: Esds { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"esds" }, + version: 0, + flags: 0, + }, + es_descriptor: EsDescriptor { + header: DescriptorHeader { tag: 3.into() }, + es_id: 2, + depends_on_es_id: None, + ocr_es_id: None, + stream_priority: 0, + url: None, + decoder_config: Some(DecoderConfigDescriptor { + header: DescriptorHeader { tag: 4.into() }, + avg_bitrate: 128000, + buffer_size_db: 0, + max_bitrate: 128000, + reserved: 1, + stream_type: 5, + object_type_indication: 64, + up_stream: false, + decoder_specific_info: Some( + DecoderSpecificInfoDescriptor { + header: DescriptorHeader { tag: 5.into() }, + data: Bytes::from_static(b"\x11\x90V\xe5\0"), + } + ), + unknown: vec![], + }), + sl_config: Some(SLConfigDescriptor { + header: DescriptorHeader { tag: 6.into() }, + predefined: 2, + data: Bytes::new(), + }), + unknown: vec![], + }, + unknown: vec![], + }, + btrt: Some(Btrt { + header: BoxHeader { box_type: *b"btrt" }, + buffer_size_db: 0, + max_bitrate: 128000, + avg_bitrate: 128000, + }), + unknown: vec![], + })], + }, + co64: None, + ctts: None, + padb: None, + sbgp: None, + sdtp: None, + stdp: None, + stco: Stco { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stco" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsc: Stsc { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsc" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stsh: None, + stss: None, + stsz: Some(Stsz { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stsz" }, + version: 0, + flags: 0, + }, + sample_size: 0, + samples: vec![], + }), + stts: Stts { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"stts" }, + version: 0, + flags: 0, + }, + entries: vec![], + }, + stz2: None, + subs: None, + unknown: vec![], + }, + hmhd: None, + nmhd: None, + vmhd: None, + unknown: vec![], + } + ); + } + + // mvex + assert_eq!( + moov.mvex, + Some(Mvex { + header: BoxHeader { box_type: *b"mvex" }, + mehd: None, + trex: vec![ + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 1, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + Trex { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trex" }, + version: 0, + flags: 0, + }, + track_id: 2, + default_sample_description_index: 1, + default_sample_duration: 0, + default_sample_size: 0, + default_sample_flags: 0, + }, + ], + unknown: vec![], + }) + ) + } + + // moof + { + let moof = boxes[2].as_moof().expect("moof"); + assert_eq!( + moof, + &Moof { + header: BoxHeader { box_type: *b"moof" }, + mfhd: Mfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"mfhd" }, + version: 0, + flags: 0, + }, + sequence_number: 1, + }, + traf: vec![ + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 1, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(256), + default_sample_size: Some(1873), + default_sample_flags: Some(0x1010000.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_SAMPLE_COMPOSITION_TIME_OFFSET + | Trun::FLAG_FIRST_SAMPLE_FLAGS + | Trun::FLAG_DATA_OFFSET + }, + data_offset: Some(192), + first_sample_flags: Some(33554432.into()), + samples: vec![TrunSample { + composition_time_offset: Some(512), + duration: None, + flags: None, + size: None, + },], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + Traf { + header: BoxHeader { box_type: *b"traf" }, + tfhd: Tfhd { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfhd" }, + version: 0, + flags: Tfhd::DEFAULT_BASE_IS_MOOF_FLAG + | Tfhd::DEFAULT_SAMPLE_FLAGS_FLAG + | Tfhd::DEFAULT_SAMPLE_SIZE_FLAG + | Tfhd::DEFAULT_SAMPLE_DURATION_FLAG, + }, + track_id: 2, + base_data_offset: None, + sample_description_index: None, + default_sample_duration: Some(1024), + default_sample_size: Some(24), + default_sample_flags: Some(0x2000000.into()), + }, + tfdt: Some(Tfdt { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"tfdt" }, + version: 1, + flags: 0, + }, + base_media_decode_time: 0, + }), + trun: Some(Trun { + header: FullBoxHeader { + header: BoxHeader { box_type: *b"trun" }, + version: 0, + flags: Trun::FLAG_DATA_OFFSET, + }, + data_offset: Some(2065), + first_sample_flags: None, + samples: vec![TrunSample { + composition_time_offset: None, + duration: None, + flags: None, + size: None, + },], + }), + sbgp: None, + subs: None, + unknown: vec![], + }, + ], + unknown: vec![], + } + ); + } + + // mdat + { + let mdat = boxes[3].as_mdat().expect("mdat"); + assert_eq!(mdat.data.len(), 1); + assert_eq!(mdat.data[0].len(), 1905 - 8); // 8 is mdat header size + } +} + +#[test] +fn test_mux_av1_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + let data = std::fs::read(dir.join("av1_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + let mut writer = io::Cursor::new(Vec::new()); + for box_ in &boxes { + box_.mux(&mut writer).unwrap(); + } + + let data = Bytes::from(writer.into_inner()); + let mut reader = io::Cursor::new(data.clone()); + + let mut new_boxes = Vec::new(); + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + new_boxes.push(box_); + } + + assert_eq!(boxes, new_boxes); + + // Pipe into ffprobe to check the output is valid. + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + let _ = ffprobe.stdin.as_mut().unwrap().write_all(&data); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["nb_streams"], 2); + assert_eq!(json["format"]["probe_score"], 100); + + assert_eq!(json["streams"][0]["codec_name"], "av1"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["avg_frame_rate"], "30/1"); + assert_eq!(json["streams"][0]["r_frame_rate"], "30/1"); + assert_eq!(json["streams"][0]["height"], 852); + assert_eq!(json["streams"][0]["width"], 480); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "44100"); + assert_eq!(json["streams"][1]["channels"], 1); +} + +#[test] +fn test_mux_hevc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + let data = std::fs::read(dir.join("hevc_aac_fragmented.mp4").to_str().unwrap()).unwrap(); + + let mut boxes = Vec::new(); + let mut reader = io::Cursor::new(data.into()); + + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + boxes.push(box_); + } + + let mut writer = io::Cursor::new(Vec::new()); + for box_ in &boxes { + box_.mux(&mut writer).unwrap(); + } + + let data = Bytes::from(writer.into_inner()); + let mut reader = io::Cursor::new(data.clone()); + + let mut new_boxes = Vec::new(); + while reader.has_remaining() { + let box_ = DynBox::demux(&mut reader).unwrap(); + new_boxes.push(box_); + } + + assert_eq!(boxes, new_boxes); + + // Pipe into ffprobe to check the output is valid. + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + let _ = ffprobe.stdin.as_mut().unwrap().write_all(&data); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["nb_streams"], 2); + assert_eq!(json["format"]["probe_score"], 100); + + assert_eq!(json["streams"][0]["codec_name"], "hevc"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["avg_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["r_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["height"], 2160); + assert_eq!(json["streams"][0]["width"], 3840); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "48000"); + assert_eq!(json["streams"][1]["channels"], 2); +} diff --git a/video/container/mp4/src/tests/mod.rs b/video/container/mp4/src/tests/mod.rs new file mode 100644 index 00000000..74c45932 --- /dev/null +++ b/video/container/mp4/src/tests/mod.rs @@ -0,0 +1 @@ +mod demux; diff --git a/video/edge/Cargo.toml b/video/edge/Cargo.toml index 8dbb558f..54a0d9bf 100644 --- a/video/edge/Cargo.toml +++ b/video/edge/Cargo.toml @@ -6,9 +6,40 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.68" -tracing = "0.1.37" -tokio = { version = "1.25.0", features = ["full"] } -serde = { version = "1.0.152", features = ["derive"] } -hyper = { version = "0.14.24", features = ["full"] } +anyhow = "1" +tracing = "0" +native-tls = "0" +tokio-native-tls = "0" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +hyper = { version = "0", features = ["full"] } +tonic = { version = "0", features = ["tls"] } +chrono = { version = "0", default-features = false, features = ["clock"] } +prost = "0" +async-stream = "0" +futures = "0" +futures-util = "0" +bytes = "1" +async-trait = "0" +fred = { version = "6", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } +url-parse = "1" +nix = "0" +sha2 = "0" +tokio-util = "0" +tokio-stream = "0" +serde_json = "1" +routerify = "3" +uuid = "1" +url = "2" + common = { path = "../../common" } + +[build-dependencies] +tonic-build = "0" +prost-build = "0" + +[dev-dependencies] +dotenvy = "0" +portpicker = "0" +serial_test = "2" +tempfile = "3" diff --git a/video/edge/build.rs b/video/edge/build.rs new file mode 100644 index 00000000..3c3ceedf --- /dev/null +++ b/video/edge/build.rs @@ -0,0 +1,23 @@ +const PROTO_DIR: &str = "../../proto"; + +fn main() { + let mut config = prost_build::Config::new(); + + config.protoc_arg("--experimental_allow_proto3_optional"); + config.bytes(["."]); + + tonic_build::configure() + .compile_with_config( + config, + &[ + format!("{}/scuffle/events/ingest.proto", PROTO_DIR), + format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), + format!("{}/scuffle/backend/api.proto", PROTO_DIR), + format!("{}/scuffle/video/ingest.proto", PROTO_DIR), + format!("{}/scuffle/video/transcoder.proto", PROTO_DIR), + format!("{}/scuffle/utils/health.proto", PROTO_DIR), + ], + &[PROTO_DIR], + ) + .unwrap(); +} diff --git a/video/edge/src/config.rs b/video/edge/src/config.rs index e2c645b3..f0500099 100644 --- a/video/edge/src/config.rs +++ b/video/edge/src/config.rs @@ -1,25 +1,166 @@ +use std::net::SocketAddr; + use anyhow::Result; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(default)] -pub struct AppConfig { +pub struct TlsConfig { + /// Domain name to use for TLS + /// Only used for gRPC TLS connections + pub domain: Option, + + /// The path to the TLS certificate + pub cert: String, + + /// The path to the TLS private key + pub key: String, + + /// The path to the TLS CA certificate + pub ca_cert: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct EdgeConfig { + /// Bind Address + pub bind_address: SocketAddr, + + /// If we should use TLS + pub tls: Option, +} + +impl Default for EdgeConfig { + fn default() -> Self { + Self { + bind_address: "[::]:9080".to_string().parse().unwrap(), + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct GrpcConfig { + /// The bind address for the gRPC server + pub bind_address: SocketAddr, + + /// If we should use TLS for the gRPC server + pub tls: Option, +} + +impl Default for GrpcConfig { + fn default() -> Self { + Self { + bind_address: "[::]:50055".to_string().parse().unwrap(), + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct RedisConfig { + /// The address of the Redis server + pub addresses: Vec, + + /// Number of connections to keep in the pool + pub pool_size: usize, + + /// The username to use for authentication + pub username: Option, + + /// The password to use for authentication + pub password: Option, + + /// The database to use + pub database: u8, + + /// The TLS configuration + pub tls: Option, + + /// To use Redis Sentinel + pub sentinel: Option, +} + +impl Default for RedisConfig { + fn default() -> Self { + Self { + addresses: vec!["localhost:6379".to_string()], + pool_size: 10, + username: None, + password: None, + database: 0, + tls: None, + sentinel: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RedisSentinelConfig { + /// The master group name + pub service_name: String, +} + +impl Default for RedisSentinelConfig { + fn default() -> Self { + Self { + service_name: "myservice".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct LoggingConfig { /// The log level to use, this is a tracing env filter - pub log_level: String, + pub level: String, + + /// If we should use JSON logging + pub json: bool, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: "info".to_string(), + json: false, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct AppConfig { + /// Name of this instance + pub name: String, /// The path to the config file. pub config_file: String, - /// Bind address for the Edge API - pub bind_address: String, + /// The log level to use, this is a tracing env filter + pub logging: LoggingConfig, + + /// API client configuration + pub edge: EdgeConfig, + + /// gRPC server configuration + pub grpc: GrpcConfig, + + /// Redis configuration + pub redis: RedisConfig, } impl Default for AppConfig { fn default() -> Self { Self { - log_level: "edge=info".to_string(), + name: "scuffle-transcoder".to_string(), config_file: "config".to_string(), - bind_address: "[::]:8080".to_string(), + edge: EdgeConfig::default(), + grpc: GrpcConfig::default(), + logging: LoggingConfig::default(), + redis: RedisConfig::default(), } } } diff --git a/video/edge/src/edge/error.rs b/video/edge/src/edge/error.rs new file mode 100644 index 00000000..8dd8613c --- /dev/null +++ b/video/edge/src/edge/error.rs @@ -0,0 +1,162 @@ +use std::{ + fmt::{Debug, Display}, + panic::Location, +}; + +use hyper::{Body, StatusCode}; +use serde_json::json; + +use super::macros::make_response; + +pub type Result = std::result::Result; + +pub struct RouteError { + source: Option, + location: &'static Location<'static>, + span: tracing::Span, + response: hyper::Response, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShouldLog { + Yes, + Debug, + No, +} + +impl RouteError { + pub fn span(&self) -> &tracing::Span { + &self.span + } + + pub fn location(&self) -> &'static Location<'static> { + self.location + } + + pub fn response(self) -> hyper::Response { + self.response + } + + pub fn should_log(&self) -> ShouldLog { + match self.response.status().is_server_error() { + true => ShouldLog::Yes, + false => match self.source.is_some() { + true => ShouldLog::Debug, + false => ShouldLog::No, + }, + } + } + + fn with_source(self, source: Option) -> Self { + Self { source, ..self } + } + + fn with_location(self, location: &'static Location<'static>) -> Self { + Self { location, ..self } + } +} + +impl From> for RouteError { + #[track_caller] + fn from(res: hyper::Response) -> Self { + Self { + source: None, + span: tracing::Span::current(), + location: Location::caller(), + response: res, + } + } +} + +impl From<(StatusCode, &'_ str)> for RouteError { + #[track_caller] + fn from(status: (StatusCode, &'_ str)) -> Self { + Self { + source: None, + span: tracing::Span::current(), + location: Location::caller(), + response: make_response!(status.0, json!({ "message": status.1, "success": false })), + } + } +} + +impl From<(StatusCode, &'_ str, T)> for RouteError +where + T: Into + Debug + Display, +{ + #[track_caller] + fn from(status: (StatusCode, &'_ str, T)) -> Self { + Self { + source: Some(status.2.into()), + span: tracing::Span::current(), + location: Location::caller(), + response: make_response!(status.0, json!({ "message": status.1, "success": false })), + } + } +} + +impl From<&'_ str> for RouteError { + #[track_caller] + fn from(message: &'_ str) -> Self { + Self { + source: None, + span: tracing::Span::current(), + location: Location::caller(), + response: make_response!( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": message, "success": false }) + ), + } + } +} + +impl Debug for RouteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.source { + Some(err) => write!(f, "RouteError: {:?}", err), + None => write!(f, "RouteError: Unknown Source"), + } + } +} + +impl Display for RouteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.source { + Some(err) => write!(f, "RouteError: {}", err), + None => write!(f, "RouteError: Unknown Source"), + } + } +} + +impl std::error::Error for RouteError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.source { + Some(err) => Some(err.as_ref()), + None => None, + } + } +} + +pub trait ResultExt: Sized { + fn map_err_route(self, ctx: C) -> Result + where + RouteError: From; +} + +impl ResultExt for std::result::Result +where + anyhow::Error: From, +{ + #[track_caller] + fn map_err_route(self, ctx: C) -> Result + where + RouteError: From, + { + match self { + Ok(val) => Ok(val), + Err(err) => Err(RouteError::from(ctx) + .with_source(Some(err.into())) + .with_location(Location::caller())), + } + } +} diff --git a/video/edge/src/edge/ext.rs b/video/edge/src/edge/ext.rs new file mode 100644 index 00000000..3cb375c3 --- /dev/null +++ b/video/edge/src/edge/ext.rs @@ -0,0 +1,25 @@ +use std::sync::{Arc, Weak}; + +use hyper::{Body, Request, StatusCode}; +use routerify::prelude::RequestExt as _; + +use crate::global::GlobalState; + +use super::error::Result; + +pub trait RequestExt { + fn get_global(&self) -> Result>; +} + +impl RequestExt for Request { + fn get_global(&self) -> Result> { + Ok(self + .data::>() + .expect("global state not set") + .upgrade() + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to upgrade global state", + ))?) + } +} diff --git a/video/edge/src/edge/macros.rs b/video/edge/src/edge/macros.rs new file mode 100644 index 00000000..08e2299f --- /dev/null +++ b/video/edge/src/edge/macros.rs @@ -0,0 +1,11 @@ +macro_rules! make_response { + ($status:expr, $body:expr) => { + hyper::Response::builder() + .status($status) + .header("Content-Type", "application/json") + .body(Body::from($body.to_string())) + .expect("failed to build response") + }; +} + +pub(super) use make_response; diff --git a/video/edge/src/edge/mod.rs b/video/edge/src/edge/mod.rs index 6b340828..97be735f 100644 --- a/video/edge/src/edge/mod.rs +++ b/video/edge/src/edge/mod.rs @@ -1,42 +1,149 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{sync::Arc, time::Duration}; use anyhow::Result; -use hyper::{service::service_fn, Body, Request, Response, StatusCode}; -use tokio::net::TcpListener; -use tracing::instrument; +use common::prelude::FutureTimeout; +use hyper::http::header; +use hyper::{server::conn::Http, Body, Response, StatusCode}; +use routerify::{Middleware, RequestInfo, RequestServiceBuilder, Router}; +use serde_json::json; +use tokio::net::TcpSocket; +use tokio::select; -use crate::config::AppConfig; +use crate::{edge::macros::make_response, global::GlobalState}; -#[instrument(name = "hello_world", skip(req), fields(method = req.method().to_string(), path = &req.uri().path()))] -async fn hello_world(req: Request) -> Result> { - tracing::debug!("Hii there!"); +use self::error::{RouteError, ShouldLog}; - Ok(Response::new("Hello, World".into())) +mod error; +mod ext; +mod macros; +mod stream; + +async fn error_handler( + err: Box<(dyn std::error::Error + Send + Sync + 'static)>, + info: RequestInfo, +) -> Response { + match err.downcast::() { + Ok(err) => { + let location = err.location(); + + err.span().in_scope(|| match err.should_log() { + ShouldLog::Yes => { + tracing::error!(location = location.to_string(), error = ?err, "http error") + } + ShouldLog::Debug => { + tracing::debug!(location = location.to_string(), error = ?err, "http error") + } + ShouldLog::No => (), + }); + + err.response() + } + Err(err) => { + tracing::error!(error = ?err, info = ?info, "unhandled http error"); + make_response!( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Internal Server Error", "success": false }) + ) + } + } } -pub async fn run(config: Arc) -> Result<()> { - let addr: SocketAddr = config.bind_address.parse()?; +pub fn cors_middleware(_: &Arc) -> Middleware { + Middleware::post(|mut resp| async move { + resp.headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap()); + resp.headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_METHODS, "*".parse().unwrap()); + resp.headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_HEADERS, "*".parse().unwrap()); + resp.headers_mut().insert( + header::ACCESS_CONTROL_MAX_AGE, + Duration::from_secs(86400) + .as_secs() + .to_string() + .parse() + .unwrap(), + ); - tracing::info!("Listening on {}", addr); - let listener = TcpListener::bind(&addr).await?; + Ok(resp) + }) +} + +pub fn routes(global: &Arc) -> Router { + let weak = Arc::downgrade(global); + Router::builder() + .data(weak) + // Our error handler + .err_handler_with_info(error_handler) + .middleware(cors_middleware(global)) + .scope("/", stream::routes(global)) + .build() + .expect("failed to build router") +} + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("Listening on {}", global.config.edge.bind_address); + let socket = if global.config.edge.bind_address.is_ipv6() { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + + socket.set_reuseaddr(true)?; + socket.set_reuseport(true)?; + socket.bind(global.config.edge.bind_address)?; + let listener = socket.listen(1024)?; + + let tls_acceptor = if let Some(tls) = &global.config.edge.tls { + tracing::info!("TLS enabled"); + let cert = std::fs::read(&tls.cert).expect("failed to read rtmp cert"); + let key = std::fs::read(&tls.key).expect("failed to read rtmp key"); + + Some(Arc::new(tokio_native_tls::TlsAcceptor::from( + native_tls::TlsAcceptor::new(native_tls::Identity::from_pkcs8(&cert, &key)?)?, + ))) + } else { + None + }; + + // The reason we use a Weak reference to the global state is because we don't want to block the shutdown + // When a keep-alive connection is open, the request service will still be alive, and will still be holding a reference to the global state + // If we used an Arc, the global state would never be dropped, and the shutdown would never complete + // By using a Weak reference, we can check if the global state is still alive, and if it isn't, we can stop accepting new connections + let request_service = + RequestServiceBuilder::new(routes(&global)).expect("failed to build request service"); loop { - let (socket, _) = listener.accept().await?; - - tracing::debug!("Accepted connection from {}", socket.peer_addr()?); - - let conn = hyper::server::conn::Http::new().serve_connection( - socket, - service_fn(|req| async { - match req.uri().path() { - "/hello" => hello_world(req).await, - _ => Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body("Not Found".into())?), - } - }), - ); + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = listener.accept() => { + let (socket, addr) = r?; + + let tls_acceptor = tls_acceptor.clone(); + let service = request_service.build(addr); + + tracing::debug!("Accepted connection from {}", addr); - tokio::spawn(conn); + tokio::spawn(async move { + if let Some(tls_acceptor) = tls_acceptor { + let Ok(Ok(socket)) = tls_acceptor.accept(socket).timeout(Duration::from_secs(5)).await else { + return; + }; + tracing::debug!("TLS handshake complete"); + Http::new().serve_connection( + socket, + service, + ).with_upgrades().await.ok(); + } else { + Http::new().serve_connection( + socket, + service, + ).with_upgrades().await.ok(); + } + }); + }, + } } } diff --git a/video/edge/src/edge/stream.rs b/video/edge/src/edge/stream.rs new file mode 100644 index 00000000..96204d00 --- /dev/null +++ b/video/edge/src/edge/stream.rs @@ -0,0 +1,371 @@ +use std::convert::Infallible; +use std::{collections::HashMap, sync::Arc}; + +use bytes::Bytes; +use futures::stream; +use hyper::{Body, Request, Response, StatusCode}; +use routerify::{prelude::RequestExt, Router}; + +use super::error::{Result, RouteError}; +use crate::{edge::ext::RequestExt as _, global::GlobalState}; +use fred::interfaces::HashesInterface; +use fred::interfaces::KeysInterface; + +pub async fn variant_playlist(req: Request) -> Result> { + let global = req.get_global()?; + + let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + + tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, "variant_playlist"); + + let params: HashMap = req + .uri() + .query() + .map(|v| { + url::form_urlencoded::parse(v.as_bytes()) + .into_owned() + .collect() + }) + .unwrap_or_else(HashMap::new); + + // LL-HLS allows for a few query parameters: + // - _HLS_msn (Media Sequence Number) + // - _HLS_part (Part Number) + + // If those are present we should block until the requested sequence number is available. + + let sequence_number = params.get("_HLS_msn").and_then(|v| v.parse::().ok()); + let part_number = params.get("_HLS_part").and_then(|v| v.parse::().ok()); + + if sequence_number.is_none() && part_number.is_some() { + return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + } + + if let Some(sequence_number) = sequence_number { + let part_number = part_number.unwrap_or_default(); + + let mut count = 0; + + loop { + if count > 10 { + return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + } + + let fields: Vec = global + .redis + .hmget( + &format!("transcoder:{}:{}:state", stream_id, variant_id), + vec![ + "current_segment_idx".to_string(), + "current_fragment_idx".to_string(), + ], + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + + let current_segment_idx: u64 = fields[0].parse::().unwrap_or_default(); + let current_fragment_idx: u64 = fields[1].parse::().unwrap_or_default(); + + tracing::info!( + sequence_number = sequence_number, + current_segment_idx = current_segment_idx, + part_number = part_number, + current_fragment_idx = current_fragment_idx, + "waiting for sequence number" + ); + + if sequence_number > current_segment_idx + 3 { + return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + } + + if sequence_number < current_segment_idx + || (sequence_number == current_segment_idx && part_number < current_fragment_idx) + { + break; + } + + count += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + } + + let playlist: String = global + .redis + .hget( + &format!("transcoder:{}:{}:state", stream_id, variant_id), + "playlist", + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + + if playlist.is_empty() { + return Err((StatusCode::NOT_FOUND, "Not found").into()); + } + + Ok(Response::builder() + .header("Content-Type", "application/vnd.apple.mpegurl") + .header("Cache-Control", "no-cache") + .body(Body::from(playlist)) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?) +} + +pub async fn master_playlist(req: Request) -> Result> { + let global = req.get_global()?; + + let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + + tracing::info!(stream_id = ?stream_id, "master_playlist"); + + let playlist: String = global + .redis + .get(&format!("transcoder:{}:playlist", stream_id)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + + if playlist.is_empty() { + return Err((StatusCode::NOT_FOUND, "Not found").into()); + } + + Ok(Response::builder() + .header("Content-Type", "application/vnd.apple.mpegurl") + .header("Cache-Control", "no-cache") + .body(Body::from(playlist)) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?) +} + +pub async fn segment(req: Request) -> Result> { + let global = req.get_global()?; + + let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let segment = req.param("segment").unwrap(); + + tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, segment = ?segment, "segment"); + + if segment.contains('.') { + let (segment, part) = segment.split_once('.').unwrap(); + let part_number = part + .parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, "Bad Request"))?; + let sequence_number = segment + .parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, "Bad Request"))?; + + let mut count = 0; + loop { + if count > 10 { + return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + } + + let fields: Vec = global + .redis + .hmget( + &format!("transcoder:{}:{}:state", stream_id, variant_id), + vec![ + "current_segment_idx".to_string(), + "current_fragment_idx".to_string(), + ], + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + + let current_segment_idx: u64 = fields[0].parse::().unwrap_or_default(); + let current_fragment_idx: u64 = fields[1].parse::().unwrap_or_default(); + + tracing::info!( + sequence_number = sequence_number, + current_segment_idx = current_segment_idx, + part_number = part_number, + current_fragment_idx = current_fragment_idx, + "waiting for sequence number" + ); + + if sequence_number > current_segment_idx + 3 { + return Err((StatusCode::BAD_REQUEST, "Bad Request").into()); + } + + if sequence_number < current_segment_idx + || (sequence_number == current_segment_idx && part_number < current_fragment_idx) + { + break; + } + + count += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let part: Option = global + .redis + .hget( + &format!("transcoder:{}:{}:{}:data", stream_id, variant_id, segment), + part_number.to_string(), + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + let Some(part) = part else { + return Err((StatusCode::NOT_FOUND, "Not found").into()); + }; + + return Ok(Response::builder() + .header("Content-Type", "video/mp4") + .header("Cache-Control", "max-age=31536000") + .body(Body::from(part)) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?); + } + + let state: Vec = global + .redis + .hmget( + &format!("transcoder:{}:{}:{}:state", stream_id, variant_id, segment), + vec!["ready".to_string(), "fragment_count".to_string()], + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + if state[0] != "true" { + return Err((StatusCode::NOT_FOUND, "Not found").into()); + } + + let mut data: HashMap = global + .redis + .hgetall(&format!( + "transcoder:{}:{}:{}:data", + stream_id, variant_id, segment + )) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + + let mut data_vec: Vec> = vec![]; + for i in 0..state[1].parse::().unwrap_or_default() { + let Some(data) = data.remove(&i.to_string()) else { + return Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into()); + }; + + data_vec.push(Ok(data)); + } + + Ok(Response::builder() + .header("Content-Type", "video/mp4") + .header("Cache-Control", "max-age=31536000") + .body(Body::wrap_stream(stream::iter(data_vec))) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?) +} + +pub async fn init_segment(req: Request) -> Result> { + let global = req.get_global()?; + + let stream_id = uuid::Uuid::parse_str(req.param("stream_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + let variant_id = uuid::Uuid::parse_str(req.param("variant_id").unwrap()) + .map_err(|_| (StatusCode::NOT_FOUND, "Not found"))?; + + tracing::info!(stream_id = ?stream_id, variant_id = ?variant_id, "init segment"); + + let part: Option = global + .redis + .get(&format!("transcoder:{}:{}:init", stream_id, variant_id)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?; + let Some(part) = part else { + return Err((StatusCode::NOT_FOUND, "Not found").into()); + }; + + Ok(Response::builder() + .header("Content-Type", "video/mp4") + .header("Cache-Control", "max-age=31536000") + .body(Body::from(part)) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + e, + ) + })?) +} + +pub fn routes(_: &Arc) -> Router { + Router::builder() + .get("/:stream_id/:variant_id/index.m3u8", variant_playlist) + .get("/:stream_id/:variant_id/init.mp4", init_segment) + .get("/:stream_id/master.m3u8", master_playlist) + .get("/:stream_id/:variant_id/:segment.mp4", segment) + .build() + .expect("failed to build router") +} diff --git a/video/edge/src/global.rs b/video/edge/src/global.rs new file mode 100644 index 00000000..e9ed09cc --- /dev/null +++ b/video/edge/src/global.rs @@ -0,0 +1,95 @@ +use common::context::Context; +use fred::{ + pool::RedisPool, + types::{PerformanceConfig, ReconnectPolicy, RedisConfig, ServerConfig}, +}; + +use crate::config::AppConfig; + +pub struct GlobalState { + pub config: AppConfig, + pub ctx: Context, + pub redis: RedisPool, +} + +impl GlobalState { + pub fn new(config: AppConfig, ctx: Context, redis: RedisPool) -> Self { + Self { config, ctx, redis } + } +} + +pub fn setup_redis(config: &AppConfig) -> RedisPool { + let mut redis_config = RedisConfig::default(); + let performance = PerformanceConfig::default(); + let policy = ReconnectPolicy::default(); + + redis_config.database = Some(config.redis.database); + redis_config.username = config.redis.username.clone(); + redis_config.password = config.redis.password.clone(); + + redis_config.server = if let Some(sentinel) = &config.redis.sentinel { + let addresses = config + .redis + .addresses + .iter() + .map(|a| { + let mut parts = a.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + (host, port) + }) + .collect::>(); + + ServerConfig::new_sentinel(addresses, sentinel.service_name.clone()) + } else { + let server = config.redis.addresses.first().expect("no redis addresses"); + if config.redis.addresses.len() > 1 { + tracing::warn!("multiple redis addresses, only using first: {}", server); + } + + let mut parts = server.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + ServerConfig::new_centralized(host, port) + }; + + redis_config.tls = if let Some(tls) = &config.redis.tls { + let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); + let key = std::fs::read(&tls.key).expect("failed to read redis key"); + let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); + + Some( + fred::native_tls::TlsConnector::builder() + .identity( + native_tls::Identity::from_pkcs8(&cert, &key) + .expect("failed to parse redis cert/key"), + ) + .add_root_certificate( + native_tls::Certificate::from_pem(&ca_cert).expect("failed to parse redis ca"), + ) + .build() + .expect("failed to build redis tls") + .into(), + ) + } else { + None + }; + + RedisPool::new( + redis_config, + Some(performance), + Some(policy), + config.redis.pool_size, + ) + .expect("failed to create redis pool") +} diff --git a/video/edge/src/grpc/health.rs b/video/edge/src/grpc/health.rs new file mode 100644 index 00000000..8f63d9ff --- /dev/null +++ b/video/edge/src/grpc/health.rs @@ -0,0 +1,66 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status}; + +use crate::pb::health::{ + health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, +}; + +pub struct HealthServer { + global: Weak, +} + +impl HealthServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl health_server::Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + let serving = self + .global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + Ok(Response::new(HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + })) + } + + async fn watch(&self, _: Request) -> Result> { + let global = self.global.clone(); + + let output = try_stream! { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let serving = global.upgrade().map(|g| !g.ctx.is_done()).unwrap_or_default(); + + yield HealthCheckResponse { + status: if serving { ServingStatus::Serving.into() } else { ServingStatus::NotServing.into() }, + }; + } + }; + + Ok(Response::new(Box::pin(output))) + } +} diff --git a/video/edge/src/grpc/mod.rs b/video/edge/src/grpc/mod.rs new file mode 100644 index 00000000..9c066843 --- /dev/null +++ b/video/edge/src/grpc/mod.rs @@ -0,0 +1,53 @@ +use crate::{ + global::GlobalState, + pb::{health::health_server, scuffle::video::transcoder_server}, +}; +use anyhow::Result; +use std::sync::Arc; +use tokio::select; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; + +pub mod health; +pub mod transcoder; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); + + let server = if let Some(tls) = &global.config.grpc.tls { + let cert = tokio::fs::read(&tls.cert).await?; + let key = tokio::fs::read(&tls.key).await?; + let ca_cert = tokio::fs::read(&tls.ca_cert).await?; + tracing::info!("gRPC TLS enabled"); + Server::builder().tls_config( + ServerTlsConfig::new() + .identity(Identity::from_pem(cert, key)) + .client_ca_root(Certificate::from_pem(ca_cert)), + )? + } else { + tracing::info!("gRPC TLS disabled"); + Server::builder() + } + .add_service(transcoder_server::TranscoderServer::new( + transcoder::TranscoderServer::new(&global), + )) + .add_service(health_server::HealthServer::new(health::HealthServer::new( + &global, + ))) + .serve_with_shutdown(global.config.grpc.bind_address, async { + global.ctx.done().await; + }); + + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = server => { + if let Err(r) = r { + tracing::error!("gRPC server failed: {:?}", r); + return Err(r.into()); + } + }, + } + + Ok(()) +} diff --git a/video/edge/src/grpc/transcoder.rs b/video/edge/src/grpc/transcoder.rs new file mode 100644 index 00000000..c627fb62 --- /dev/null +++ b/video/edge/src/grpc/transcoder.rs @@ -0,0 +1,24 @@ +#![allow(dead_code)] +// TODO: Remove this once we have a real implementation + +use crate::{global::GlobalState, pb::scuffle::video::transcoder_server}; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Status}; + +pub struct TranscoderServer { + global: Weak, +} + +impl TranscoderServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl transcoder_server::Transcoder for TranscoderServer {} diff --git a/video/edge/src/main.rs b/video/edge/src/main.rs index 8ce4e056..463be48a 100644 --- a/video/edge/src/main.rs +++ b/video/edge/src/main.rs @@ -1,24 +1,66 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use anyhow::Result; -use common::logging; -use tokio::select; +use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use tokio::{select, signal::unix::SignalKind, time}; mod config; mod edge; +mod global; +mod grpc; +mod pb; #[tokio::main] async fn main() -> Result<()> { - let config = Arc::new(config::AppConfig::parse()?); + let config = config::AppConfig::parse()?; - logging::init(&config.log_level)?; + logging::init(&config.logging.level, config.logging.json)?; - tracing::info!("starting"); + tracing::info!("starting: loaded config from {}", config.config_file); + + let (ctx, handler) = Context::new(); + + let redis = global::setup_redis(&config); + redis.connect(); + + redis + .wait_for_connect() + .timeout(Duration::from_secs(2)) + .await + .expect("failed to connect to redis") + .expect("failed to connect to redis"); + tracing::info!("connected to redis"); + + let global = Arc::new(global::GlobalState::new(config, ctx, redis)); + + let edge_future = tokio::spawn(edge::run(global.clone())); + let grpc_future = tokio::spawn(grpc::run(global.clone())); + + // Listen on both sigint and sigterm and cancel the context when either is received + let mut signal_handler = signal::SignalHandler::new() + .with_signal(SignalKind::interrupt()) + .with_signal(SignalKind::terminate()); select! { - _ = edge::run(config.clone()) => tracing::info!("edge stopped"), - _ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received"), + r = edge_future => tracing::error!("transcoder stopped unexpectedly: {:?}", r), + r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), + _ = signal_handler.recv() => tracing::info!("shutting down"), + } + + // We cannot have a context in scope when we cancel the handler, otherwise it will deadlock. + drop(global); + + // Cancel the context + tracing::info!("waiting for tasks to finish"); + + select! { + _ = time::sleep(Duration::from_secs(60)) => tracing::warn!("force shutting down"), + _ = signal_handler.recv() => tracing::warn!("force shutting down"), + _ = handler.cancel() => tracing::info!("shutting down"), } Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/video/edge/src/pb.rs b/video/edge/src/pb.rs new file mode 100644 index 00000000..4f4bd310 --- /dev/null +++ b/video/edge/src/pb.rs @@ -0,0 +1,23 @@ +#![allow(clippy::match_single_binding)] + +pub mod scuffle { + pub mod backend { + tonic::include_proto!("scuffle.backend"); + } + + pub mod types { + tonic::include_proto!("scuffle.types"); + } + + pub mod video { + tonic::include_proto!("scuffle.video"); + } + + pub mod events { + tonic::include_proto!("scuffle.events"); + } +} + +pub mod health { + tonic::include_proto!("grpc.health.v1"); +} diff --git a/video/edge/src/tests/certs/ca.ec.crt b/video/edge/src/tests/certs/ca.ec.crt new file mode 100644 index 00000000..9436c192 --- /dev/null +++ b/video/edge/src/tests/certs/ca.ec.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBbDCCARGgAwIBAgITAQQHMJ/YiOeCwmwdOLS//1SnIjAKBggqhkjOPQQDAjAU +MRIwEAYDVQQDDAkxMjcuMC4wLjEwHhcNMjMwNDI2MDc0NTA2WhcNMjQwNDI1MDc0 +NTA2WjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwWTATBgcqhkjOPQIBBggqhkjOPQMB +BwNCAATQFyQcMa94peoJBphHsQaDVFUaHSKQEgp8/XmENO1U0pMsZ9Bbi/mV61VX +oSnNz3e0ZuikM/O4BbMZJOFQ2hvNo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBhjAdBgNVHQ4EFgQU99Fg74uP9OWTuE76fcbEeKcHI2swCgYIKoZI +zj0EAwIDSQAwRgIhANgP5OsuQrd0eNntAg/hFK8dxemQ2AJEdt4rP/K46N8iAiEA +hS1Sj/OF17SOIsOAq8lE1GZo47TLI7Wq2pmJ3kGSzJg= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/ca.ec.key b/video/edge/src/tests/certs/ca.ec.key new file mode 100644 index 00000000..1e30c6cc --- /dev/null +++ b/video/edge/src/tests/certs/ca.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXJgb1t4MQg7IH5+G +pb7AQz1dJf1P1cETYSVHUqHBkJWhRANCAATQFyQcMa94peoJBphHsQaDVFUaHSKQ +Egp8/XmENO1U0pMsZ9Bbi/mV61VXoSnNz3e0ZuikM/O4BbMZJOFQ2hvN +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/certs/ca.ini b/video/edge/src/tests/certs/ca.ini new file mode 100644 index 00000000..bf362da6 --- /dev/null +++ b/video/edge/src/tests/certs/ca.ini @@ -0,0 +1,13 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +# Since this is a CA, the key usage is critical +x509_extensions = v3_ca + +[v3_ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[dn] +CN = 127.0.0.1 diff --git a/video/edge/src/tests/certs/ca.rsa.crt b/video/edge/src/tests/certs/ca.rsa.crt new file mode 100644 index 00000000..44754f3d --- /dev/null +++ b/video/edge/src/tests/certs/ca.rsa.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+DCCAeCgAwIBAgIUF9vFd3f/op4WwWKQ2tvewBobsAEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA3sYI93c8gGWSaqtDygC+fMZDhy3Wxgg7dh+45IDQsVNp +sd8BGwrB9Ge2fjZvk/8XVa437ZDKgehodN8fBn9LrypPt2s7hPG1a3RYnd3fa5/t +QSsMt9Aodb3zymsnonxig+D/qSDqXi/ZNikbQ7+PvG1UDLtl10oGdrMHyHFcxxhW +1lEya3unffkLJaL9TDLJs9E5XqRFCKBMQVygjoF9ToMGaYD+KyIrdpaVIsVS3W5j +Jj6IADgEnJ6yTYpKEgxSgFok3yWqmFRCfdN4TfiDBAiAKCbsKxCDbFOnntOjWBW+ +HYhloh7ZSRNeYm1DSArb9Kn+iJMRxxx1fa+DOJpGRwIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQULYXilygDXYi+UFjp +pyZZLl3UUv8wDQYJKoZIhvcNAQELBQADggEBACFIxzF1MRQY1uRy+aTn1731JhBP +5rlJrfIQxF4m3PlHeOJrpc09KORSsUsC048MK2abrkC+A2SZoIQKBBL3PporKlnU +aRV8lOtIJ2X2/VTQTM9I/dCqKBzEAwyPSLn75WL9tif++nSKamWgT5Fk5I664qiP +FVzFOIXzBDNzNrWKZXsZMoqJUnj0tZiQxrfWpMeUpJr/mKSDS179ruF79jFXym7+ +dURX0xrCfeh70w+oq/xg91bpwhcQX9GWNLc7OFSnwxE4aeBZGNcttFXWB7Z8EcSp +z+QyHl/SFr89ZMqGaqkxAE6py3TsAj4TKo4vJr6+6aUrVhZcabd0SC8LH5c= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/ca.rsa.key b/video/edge/src/tests/certs/ca.rsa.key new file mode 100644 index 00000000..59be34b8 --- /dev/null +++ b/video/edge/src/tests/certs/ca.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDexgj3dzyAZZJq +q0PKAL58xkOHLdbGCDt2H7jkgNCxU2mx3wEbCsH0Z7Z+Nm+T/xdVrjftkMqB6Gh0 +3x8Gf0uvKk+3azuE8bVrdFid3d9rn+1BKwy30Ch1vfPKayeifGKD4P+pIOpeL9k2 +KRtDv4+8bVQMu2XXSgZ2swfIcVzHGFbWUTJre6d9+Qslov1MMsmz0TlepEUIoExB +XKCOgX1OgwZpgP4rIit2lpUixVLdbmMmPogAOAScnrJNikoSDFKAWiTfJaqYVEJ9 +03hN+IMECIAoJuwrEINsU6ee06NYFb4diGWiHtlJE15ibUNICtv0qf6IkxHHHHV9 +r4M4mkZHAgMBAAECggEABJPdK03v7ajTUWMjiXW/yaCT/VBxJrCUnYtuqS4HG8hd +q6IBdnofcijkvxiEl8NDdNHs+Zy4DI38rNTI4Rah33+RvnBy+1BcLKZqC78QuuLB +D/hqfSccgw7cK5Siw9v6dOtIAERaNy/p2WBkMbLbU+vghkJzh/D6y7BHa1RTq7jy +fWm67q+putp+DxbljG3U+6nMJZLagUr1guXgDQRvimQB6Ebfzqvh4MhOGhkAw/GU +c30RjZnNaChelkv/vSWqlsiFxhGpa238JoHS+oDJ3250mW1Le5/w7I2vBDtZLIaq +u3a+TOolKSkeIBcXQ1AZGR7rUlpbgOq2mUwdxFO9gQKBgQD4cFa92QkRoQGC8yCC ++vW3XlvEDO1oNp3KtdLYbzMNqXRT3WQ+u2Kmcd5ztui1BSt/fhZLUhbMbT9hBqUm +aYN9spORkfJ14VAXuOwT4zSc73rDz2FTq1S27T+hxc0IGjTZjMqsl7n1rksdsBAP +xUriDC3DIeUSo2YSMLMFWt1OfwKBgQDljbsF+2VTe9jxVpLU7sIgPl8wfwVsAB2Z +xzgcvJ2qKjZvUy/+wzJ9CN3bAhVbAOB4POfpiwVGAaoTLZBZLNRqAmlynmqO/ptP +7105hOqbhQeljqdCpyFVhvJkyV5KqeKN+2xHTLehlQa9rdLunj7iW44k2rN5Mb0R +77Or6Z40OQKBgFsU/IgvuMZwy9gRgLrkfQ9UFbqjrqpFU8ZMsNdOtV3t4UsZ4LWr +B3jUSGUOCvTKx26/cDb/CoK6DsFoqUWS63U68iUtZ8HV8AIydsK3ysM6fTyqnBkL +uEw0YN7TYN72lKepmWh7W975nmps8QaHI3QKWQCwPYZ+x14l4ow1CuvLAoGALA5x +kIpZPhaM4nS9JYTVWR7fYg1e2wWCqNrlWA6TK++CFweeNIT+EaU7/yZ9NsQKUMlP +sTDvSCpVm+yowZSrB9WCq27gAKW45TSJbdqmtEZp20pvq4ksCqAlsVY8dJP6WUmh +1GVS8P4LFyhfTVCtvP/ZXhVjUKVNJj4c+6eQp3ECgYEAlNS1Cow3TwL1DQxaHzF4 +/Uh17Cx/N5OZq8zlUTPqneQmb2udplDT3UrJyZ07N7cl0bT6nXTlLQO2Y8xbdUSy +Kj4W0J7Pi5EI3rvrE7UDsueMUF2il78fVxdfSkhVPM6KVPSoGHKYfUDDfdx4cvvz +S+jzBZmDhNiOBD5K6DNkC8g= +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/certs/client.ec.crt b/video/edge/src/tests/certs/client.ec.crt new file mode 100644 index 00000000..09d04a5e --- /dev/null +++ b/video/edge/src/tests/certs/client.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV2gAwIBAgIUfRh5k6JYsW2u7zi9IU/q2hQuTyQwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQyNTA3 +NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEc+AJ5QsBOZdLosr8Rq78nfu43XRxT5lvqjZ1KVb40UE+mlf4QXJgTh98 +7HYk/zQ60KA29UhqUFKEY9F+ucKfbKOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFBWRCfTY+nPvm0ivJfcOF8006LmfMB8GA1UdIwQYMBaA +FPfRYO+Lj/Tlk7hO+n3GxHinByNrMAoGCCqGSM49BAMCA0gAMEUCIQDe/r15qYfh +uhCTdkmef9M1c/dogrZKZJPFummB/EVKSgIgbEQfFdwHZ0ivDgpIvFqrGuQmjVVo +vLtUKoIA7HY7QOA= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/client.ec.key b/video/edge/src/tests/certs/client.ec.key new file mode 100644 index 00000000..eecfabfc --- /dev/null +++ b/video/edge/src/tests/certs/client.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgoJEehaCKnug+pV9p +2XNUX0Xc4rBJ7sq6LRJPQ39kfbihRANCAARz4AnlCwE5l0uiyvxGrvyd+7jddHFP +mW+qNnUpVvjRQT6aV/hBcmBOH3zsdiT/NDrQoDb1SGpQUoRj0X65wp9s +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/certs/client.ini b/video/edge/src/tests/certs/client.ini new file mode 100644 index 00000000..01771e66 --- /dev/null +++ b/video/edge/src/tests/certs/client.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_client + +[v3_client] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/edge/src/tests/certs/client.rsa.crt b/video/edge/src/tests/certs/client.rsa.crt new file mode 100644 index 00000000..b65ed77e --- /dev/null +++ b/video/edge/src/tests/certs/client.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUQjFHjZe3el3ZtBtSdaNL8Hypz4owDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAxKRMbZTWMw4AvLFfB6begU24ugNorcPcLIN0u0eGQgWt +UOtelp9k0FvsgpAZ+uGovPhytY03Qtn0dWKAWD5SMGlIBp8YbqLiKwG/kkLFjDxg +ouFqYzdP1cmaEwcFjHUblQ8YM3c9m4r99ZjbsmQXK8qC/3t1PWxYh34HuB1ftXxz +q+rhJUHXuaiJMqyze9Fx0QED5Lpol25HmMm9Jx1eA9de+YbB1ZnK+LCC7IxEooxs +TguKnWqsoF2QpnBVNr/yJq2h/Z/hLMpd+G0+HO2z87JCCAWQnHTmmJgPtyOX27Pb +EggH4xM4VkSm6DbAh2OEDizlzX6TjoS9PmjKrxcTnwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU2GfevHphiOsu2VKHJFwA5cH8 +/q8wHwYDVR0jBBgwFoAULYXilygDXYi+UFjppyZZLl3UUv8wDQYJKoZIhvcNAQEL +BQADggEBAFI0GRb9R5xMSdBIUHrrgJ5XTYiYNZFRTy7IozD804Dyz3jyx9sr+bRD +G3pcjGmzNT3jSwGIuEX1RXLl4ohQbvppT09+2WlH5O+3+uoYoqXN2aBbS5zuKiWN +/gc488u3/Se4hDq6UfKwuqcMQvvaYOhkjgmwfIAe8TTYNLPYdeuNw1Xxr/1AzXHY +sFGsCXZ9M7Wbt6Ln2gMvGeQMItLXEPRe2jtkZyJ/a6KqXIHvZKDccPLZBv56OfmY +TF54NQPMfONGs2thfVP4OaA7iZGIUjth+c6yq7dID28WiSRwADkZjZ9GVxuGH5c7 +PMY5VrrJptJZfg0/ZiVL8O4THqCiJtE= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/client.rsa.key b/video/edge/src/tests/certs/client.rsa.key new file mode 100644 index 00000000..64e61a24 --- /dev/null +++ b/video/edge/src/tests/certs/client.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDEpExtlNYzDgC8 +sV8Hpt6BTbi6A2itw9wsg3S7R4ZCBa1Q616Wn2TQW+yCkBn64ai8+HK1jTdC2fR1 +YoBYPlIwaUgGnxhuouIrAb+SQsWMPGCi4WpjN0/VyZoTBwWMdRuVDxgzdz2biv31 +mNuyZBcryoL/e3U9bFiHfge4HV+1fHOr6uElQde5qIkyrLN70XHRAQPkumiXbkeY +yb0nHV4D1175hsHVmcr4sILsjESijGxOC4qdaqygXZCmcFU2v/ImraH9n+Esyl34 +bT4c7bPzskIIBZCcdOaYmA+3I5fbs9sSCAfjEzhWRKboNsCHY4QOLOXNfpOOhL0+ +aMqvFxOfAgMBAAECggEACepA4kuda1Ca7+i/omqjEIEDPfnbBtv315S/R4wpNF4F +6a0chVq/IhRofgLXFIPZVsHuQsI+lZPY1CxlzU1DJqbAV3khKb7zyXhamPzd6h7H +Pp9AFoWc9GooZyo0+OqUu/TZYwrxo8yj1oThGwMZ+f7JkSk/9ZtncVmE+R8eCtAH +bytS4nQufwIgoE8YCde4FLIUkaLrtC9lkJnOz0878A7BScFb3pvI2ZaqV2tFUq01 +gAxFdK2M3jGSGMGnCNCLy2Fjx+kfCWuwikF6PRS4imX1+lTJDNTuv6XmPnHKoe8S +dkaEzI6y/IOzKgQoyH4mZtFA4/7U+4FzuLWr8k4vyQKBgQDOqWvLxBOUqfgewYQL +85pdBoIa3MQpHf8uAeZ0yWut0HbxHQGAygDQDPgLRQhSRP+MXo9saXCOvI8+rCKF +FszK/ux0Ag8mxA+p6RRhlgASDhyEIyEHCxyEEIjP4eqR1rZ6MmIfStUJicW1U4WG +fSecpav+S2YSyIuu3MO6PfJCWQKBgQDzlnursFmcQN+B18onF2/9K1ImmmGYXPS3 +b1lkjleIW8MtQCK9vGlDBD0aBnJDymSxWyWzJK0bn9SmeOjYzdND+tit5rvEUnBp +nYrKlpSSMyvPnLV7xN09asOvhKatLrr0sstcDtmLtSm2dFLOYiryN8NHtPAGzyh9 +MJ1OHCYWtwKBgQCV1pb2xbKgvl/NBOgVtkk8m4Rnr5t2aG5lUDFkicnN23DxvuMh +GtVeA5kwqpuu8qIKh2Eb7JMUmriNa0cYEgDoSc7tCbUsmUj2G62QV66zaJHaaJIA +xlillEtt1lI57WCe1rr4D0zJPqAfqXANo969oA1FMivPAKLuZNhwx4tH+QKBgE+j +zK1WjAXFRA4cslBTnl7EsihC41PAWJY8xppU25OOhOKfjHxCRJwPn7aJkwRNANzn +swy+GgblG86Ny3tO2BrqwbshrBRE69HsGzufPdYK+vD3CHL962OwK2iQUzpeA+wL +JOflRwUhZxDrOUOW3vmwd51TMALZ6h/8LAIku+NDAoGAQY4nwrU2w0pbywmHiVMO +YWqryILqyeq9wSuemSdleph/SgmOkut0L4HeB/G3mpqHtOOOMtri5A+Xy+n/QToc +scJzSPxO21PhqQHWVSxr1VsNQow8PAbLZWVIvLkXXMk4Pw18r8xQMnXCA9xBJc3V +hwL+VmO0zQEX/dtw2uTI4Qs= +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/certs/generate.sh b/video/edge/src/tests/certs/generate.sh new file mode 100755 index 00000000..b7e30a66 --- /dev/null +++ b/video/edge/src/tests/certs/generate.sh @@ -0,0 +1,23 @@ +openssl genrsa -out ca.rsa.key 2048 +openssl genrsa -out server.rsa.key 2048 +openssl genrsa -out client.rsa.key 2048 + +openssl req -x509 -sha256 -days 365 -nodes -key ca.rsa.key -config ca.ini -out ca.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key server.rsa.key -config server.ini -out server.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key client.rsa.key -config client.ini -out client.rsa.crt + +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out ca.ec.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out server.ec.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out client.ec.key + +openssl pkcs8 -topk8 -nocrypt -in ca.ec.key -out ca.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in server.ec.key -out server.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in client.ec.key -out client.ec.key.pem + +mv ca.ec.key.pem ca.ec.key +mv server.ec.key.pem server.ec.key +mv client.ec.key.pem client.ec.key + +openssl req -x509 -sha256 -days 365 -nodes -key ca.ec.key -config ca.ini -out ca.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key server.ec.key -config server.ini -out server.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key client.ec.key -config client.ini -out client.ec.crt diff --git a/video/edge/src/tests/certs/server.ec.crt b/video/edge/src/tests/certs/server.ec.crt new file mode 100644 index 00000000..c601c67b --- /dev/null +++ b/video/edge/src/tests/certs/server.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV2gAwIBAgIUM6MqBG5ljzLPkWCkE+QgILdJffMwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQyNTA3 +NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEuVIvvN9VEfcGpYgH/3TuJDqF3uwXHn+EBjLE9xOkP2ubA38Gahii2KFN +1bRY79YKjROoN47Rt5jrlqIC8PnuvKOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFNYg0oijXGfUkP2kHRXfUtgUNnk8MB8GA1UdIwQYMBaA +FPfRYO+Lj/Tlk7hO+n3GxHinByNrMAoGCCqGSM49BAMCA0gAMEUCIQD/SQQ4poga +bbULoxEVWG0PtXvmtITFWUtTismeMFP5PQIgTtiKw5rmgo2Vas2HrvAMegNAQ4Dm +nAWXjBlgck3dIyc= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/server.ec.key b/video/edge/src/tests/certs/server.ec.key new file mode 100644 index 00000000..f1195b67 --- /dev/null +++ b/video/edge/src/tests/certs/server.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVK+WX1r5NDJg3RJZ +mR+ZhJ6xD7Mj/N7AQ/kEJsEgy2mhRANCAAS5Ui+831UR9waliAf/dO4kOoXe7Bce +f4QGMsT3E6Q/a5sDfwZqGKLYoU3VtFjv1gqNE6g3jtG3mOuWogLw+e68 +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/certs/server.ini b/video/edge/src/tests/certs/server.ini new file mode 100644 index 00000000..34331807 --- /dev/null +++ b/video/edge/src/tests/certs/server.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_server + +[v3_server] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/edge/src/tests/certs/server.rsa.crt b/video/edge/src/tests/certs/server.rsa.crt new file mode 100644 index 00000000..9669a7b1 --- /dev/null +++ b/video/edge/src/tests/certs/server.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUWyHas5CdrlT3JoopnDLC3Cz3h5MwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAnXYZQRUPeNcHbY4w/HYv/U1pLruOi0haDg3Jy+0lI66M +YfJSE71TuZyGHPK5u/MJm7qByVyc74jZBnXzc07JpOuDXLsQ3obNgAwSEDQHXrV5 +HDq0zZ3GWbBYd1AZ+iPpRFuStGYrxpF/zITMKM9+GQINneMnnaIJYng5JDWZfyAB ++urnVxjTkvGsGtyFnP5+uJuTrIDWMdkHFgRjNZBTSI7R4QzvdqXuu8K4osDy89SJ +c7XtYf9+wfVEpqwXqwgAjhRlJSFbl4df/egFzzduggTk6KcrbIRaTfWTLPLfHbkF +dzn0yxHFozK9chznOBLAelCKu4p42Ei34KO5VdXFhwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU+1hGS7KCsHYDPNpxeIS7sYE5 +cOAwHwYDVR0jBBgwFoAULYXilygDXYi+UFjppyZZLl3UUv8wDQYJKoZIhvcNAQEL +BQADggEBADUvzMmV+C4Zh2XxPncSeciqXCKPENkvAWxvHWVlaXJKxIhmEMpZH4mR +L9SKWJSt7zmL0aJQUZ/yNU1aJ2mRQwtFfky3ebAcOPsa4HM02fNhgV1b/r84S40o +5fSpgcZCwJQHH7GHABbHJToCz/TxRktVFRJSEaOx0yzCGVCKwHe7+TClawGzL7z1 +6x8KLbXZInbBHEJSNFLckExFZTH09xIG33LaywI9p/6xA3VenCvePDPt2lAeEk9j +w7tus6zSA+/5PHpPOAoOiUUtmXDZGs+4s8sdu6XRAr2eRruvCzrailb9JydyJj7X +Z+QmTOWIU7puyugfBGAj39UlogNwwW8= +-----END CERTIFICATE----- diff --git a/video/edge/src/tests/certs/server.rsa.key b/video/edge/src/tests/certs/server.rsa.key new file mode 100644 index 00000000..1d4f85f0 --- /dev/null +++ b/video/edge/src/tests/certs/server.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCddhlBFQ941wdt +jjD8di/9TWkuu46LSFoODcnL7SUjroxh8lITvVO5nIYc8rm78wmbuoHJXJzviNkG +dfNzTsmk64NcuxDehs2ADBIQNAdetXkcOrTNncZZsFh3UBn6I+lEW5K0ZivGkX/M +hMwoz34ZAg2d4yedoglieDkkNZl/IAH66udXGNOS8awa3IWc/n64m5OsgNYx2QcW +BGM1kFNIjtHhDO92pe67wriiwPLz1Ilzte1h/37B9USmrBerCACOFGUlIVuXh1/9 +6AXPN26CBOTopytshFpN9ZMs8t8duQV3OfTLEcWjMr1yHOc4EsB6UIq7injYSLfg +o7lV1cWHAgMBAAECggEAMJLy/XG6wSNMRk6b6/WlnjVYIjN3qJ3cMgOs4by5PUWm +RrzS9wXroxGXCa0TANjbeO/TA8QPIZGMqYKPZF3EtJx2fI+0h4D8Oej/VYJHV78R +qlSt29Q1EQvmboAGU3Tqi8zX99Cg5nOSAgxhUqGXm61XeAJQAT+wN3Ew52ule2Jv +EqO83Uj1hDtYoEYBB2FhQfrGd/L8qN2GMnn0W0r3oiC8LlMZBIVxGRJGHAkdhJ8D +46zEmgpPyckS9XNJx4yPIDMDn3B2Yp7it8jruXar3n+SDGa77itcTRwUEsFSxnKC +H1FbTzsFp5e3O4u7OH5iONczykoZpFQ0utQysjxh0QKBgQDZtWcNXq2xqh00X+q4 +gy4Smq4TFtPxipRaewIESAnbI6iWGsyFdP1co4ryoAH8zbIgELQCfgfr7LiQj/A3 +CQfOPB49a8xI4HbXZe6yFgti45GBb8y5RHSeUPPnGACSekif8M4Vaix4w8FagPZa ++ZTQPQA3ddnGVZ5GPODR4wwE3wKBgQC5J/z8/rBPkYXipT7Zben+IzhlXRFWhHgV +afcNdBY0G1JeR37PpDPdPZ5WnSNoMRfu573GyrTOSF4I4jgLS5L8VHEPi5gJA7U7 +Wxv9ppQNRo3SODnhO/Al1369DUSWQwJ6WDjcEO1Xrc0AsIIm8LxhoV5L/CjqwJZ/ +4nrsVKZsWQKBgC9ham8fdt/erQJ0CYpkikdkQJRI+JFt3oGemb7CytpVdWBNrssw +vd9GfHv3VNdnEOgnmnWcZi7zUuurV9Uycu9waAhoCIqnx1VzirJZV9sKueUYps5/ +Vn4KEjruH1nBoUKlzsQcWldiCxeeT39XKAr167EmReIDSjHxF+C18CyzAoGADCjk +JHlVeuRDtq7DgeQGCfqmKYIDMXthp4ZeAzQsgR+KOUbYvSo7fbweOfH38U/IEpiF +jhih5yo5grvYkmVUMd4ZzruMMItdy5ggLnhSIM0RY0zuACy/iLyuRhwo9PVRpFdG +5Kz36VowrGrrIUOOG5tNZhAZX9FmEN/+0qZ8h4ECgYEA0b8giWmBFYElU17r8oRG +g8g4Op3RSuHmJnAylVxahm+CvFeClt0wtxbKAbrXwIfCoI23OyQgUmRrC+zp4V0J +wlKQivOiSUrmhuOCQShkvQdYUHoBOMvkloXq7U0MWfl5pmfKjKcpQLeeNsaOFD0h +jhq59AyODmAFTujOw+8Ly5w= +-----END PRIVATE KEY----- diff --git a/video/edge/src/tests/config.rs b/video/edge/src/tests/config.rs new file mode 100644 index 00000000..f4919198 --- /dev/null +++ b/video/edge/src/tests/config.rs @@ -0,0 +1,106 @@ +use serial_test::serial; + +use crate::config::AppConfig; + +fn clear_env() { + for (key, _) in std::env::vars() { + if key.starts_with("SCUF_") { + std::env::remove_var(key); + } + } +} + +#[serial] +#[test] +fn test_parse() { + clear_env(); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config, AppConfig::default()); +} + +#[serial] +#[test] +fn test_parse_env() { + clear_env(); + + std::env::set_var("SCUF_LOGGING__LEVEL", "edge=debug"); + std::env::set_var( + "SCUF_DATABASE__URI", + "postgres://postgres:postgres@localhost:5433/postgres", + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config.logging.level, "edge=debug"); +} + +#[serial] +#[test] +fn test_parse_file() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "edge=debug" + +[edge] +bind_address = "0.0.0.0:8080" +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "edge=debug"); + assert_eq!(config.edge.bind_address, "0.0.0.0:8080".parse().unwrap()); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} + +#[serial] +#[test] +fn test_parse_file_env() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "edge=debug" + +[edge] +bind_address = "[::]:8080" +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + std::env::set_var("SCUF_LOGGING__LEVEL", "edge=info"); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "edge=info"); + assert_eq!(config.edge.bind_address, "[::]:8080".parse().unwrap()); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} diff --git a/video/edge/src/tests/global.rs b/video/edge/src/tests/global.rs new file mode 100644 index 00000000..a6a79c6d --- /dev/null +++ b/video/edge/src/tests/global.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use common::{ + context::{Context, Handler}, + logging, +}; +use fred::pool::RedisPool; + +use crate::{config::AppConfig, global::GlobalState}; + +pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) { + let (ctx, handler) = Context::new(); + + dotenvy::dotenv().ok(); + + logging::init(&config.logging.level, config.logging.json) + .expect("failed to initialize logging"); + + let redis = RedisPool::new( + fred::types::RedisConfig::from_url( + std::env::var("REDIS_URL") + .expect("REDIS_URL not set") + .as_str(), + ) + .expect("failed to parse redis url"), + Some(Default::default()), + Some(Default::default()), + 2, + ) + .expect("failed to create redis pool"); + + let global = Arc::new(GlobalState::new(config, ctx, redis)); + + (global, handler) +} diff --git a/video/edge/src/tests/grpc/health.rs b/video/edge/src/tests/grpc/health.rs new file mode 100644 index 00000000..a174980e --- /dev/null +++ b/video/edge/src/tests/grpc/health.rs @@ -0,0 +1,107 @@ +use std::time::Duration; + +use common::grpc::make_channel; +use common::prelude::FutureTimeout; + +use crate::config::{AppConfig, GrpcConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_health_check() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_health_watch() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .watch(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + + let mut stream = resp.into_inner(); + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + + let cancel = handler.cancel(); + + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + ); + + cancel + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/edge/src/tests/grpc/mod.rs b/video/edge/src/tests/grpc/mod.rs new file mode 100644 index 00000000..a72ae5cf --- /dev/null +++ b/video/edge/src/tests/grpc/mod.rs @@ -0,0 +1,2 @@ +mod health; +mod tls; diff --git a/video/edge/src/tests/grpc/tls.rs b/video/edge/src/tests/grpc/tls.rs new file mode 100644 index 00000000..94bdbcd7 --- /dev/null +++ b/video/edge/src/tests/grpc/tls.rs @@ -0,0 +1,150 @@ +use common::grpc::{make_channel, TlsSettings}; +use common::prelude::FutureTimeout; +use std::path::PathBuf; +use std::time::Duration; +use tonic::transport::{Certificate, Identity}; + +use crate::config::{AppConfig, GrpcConfig, TlsConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_tls_rsa() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.rsa.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.rsa.crt").to_str().unwrap().to_string(), + key: dir.join("server.rsa.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = + Certificate::from_pem(std::fs::read_to_string(dir.join("ca.rsa.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.rsa.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.rsa.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_tls_ec() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.ec.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.ec.crt").to_str().unwrap().to_string(), + key: dir.join("server.ec.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = Certificate::from_pem(std::fs::read_to_string(dir.join("ca.ec.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.ec.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.ec.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/edge/src/tests/mod.rs b/video/edge/src/tests/mod.rs new file mode 100644 index 00000000..9a32ded3 --- /dev/null +++ b/video/edge/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod config; +mod global; +mod grpc; diff --git a/video/ingest/Cargo.toml b/video/ingest/Cargo.toml index 9b0ab5e8..26c720bf 100644 --- a/video/ingest/Cargo.toml +++ b/video/ingest/Cargo.toml @@ -6,9 +6,41 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.68" -tracing = "0.1.37" -tokio = { version = "1.25.0", features = ["full"] } -serde = { version = "1.0.152", features = ["derive"] } -hyper = { version = "0.14.24", features = ["full"] } +anyhow = "1" +tracing = "0" +native-tls = "0" +tokio-native-tls = "0" +async-trait = "0" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +hyper = { version = "0", features = ["full"] } +tonic = { version = "0", features = ["tls"] } +prost = "0" +bytes = "1" +futures = "0" +futures-util = "0" +chrono = { version = "0", default-features = false, features = ["clock"] } +serde_json = "1" +uuid = "1" +async-stream = "0" +pnet = "0" +lapin = { version = "2.0.3", features = ["native-tls"] } +tokio-executor-trait = "2" +tokio-reactor-trait = "1" + common = { path = "../../common" } +rtmp = { path = "../protocol/rtmp" } +bytesio = { path = "../bytesio" } +flv = { path = "../container/flv" } +transmuxer = { path = "../transmuxer" } +mp4 = { path = "../container/mp4" } + +[dev-dependencies] +dotenvy = "0" +portpicker = "0" +serial_test = "2" +tempfile = "3" + +[build-dependencies] +tonic-build = "0" +prost-build = "0" diff --git a/video/ingest/build.rs b/video/ingest/build.rs new file mode 100644 index 00000000..5a5aac82 --- /dev/null +++ b/video/ingest/build.rs @@ -0,0 +1,22 @@ +const PROTO_DIR: &str = "../../proto"; + +fn main() { + let mut config = prost_build::Config::new(); + + config.protoc_arg("--experimental_allow_proto3_optional"); + config.bytes(["."]); + + tonic_build::configure() + .compile_with_config( + config, + &[ + format!("{}/scuffle/events/ingest.proto", PROTO_DIR), + format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), + format!("{}/scuffle/backend/api.proto", PROTO_DIR), + format!("{}/scuffle/video/ingest.proto", PROTO_DIR), + format!("{}/scuffle/utils/health.proto", PROTO_DIR), + ], + &[PROTO_DIR], + ) + .unwrap(); +} diff --git a/video/ingest/src/config.rs b/video/ingest/src/config.rs index 33faf0b8..7744017b 100644 --- a/video/ingest/src/config.rs +++ b/video/ingest/src/config.rs @@ -1,21 +1,177 @@ +use std::net::SocketAddr; + use anyhow::Result; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(default)] -pub struct AppConfig { +pub struct TlsConfig { + /// Domain name to use for TLS + /// Only used for gRPC TLS connections + pub domain: Option, + + /// The path to the TLS certificate + pub cert: String, + + /// The path to the TLS private key + pub key: String, + + /// The path to the TLS CA certificate + pub ca_cert: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RtmpConfig { + /// The bind address for the RTMP server + pub bind_address: SocketAddr, + + /// If we should use TLS for the RTMP server + pub tls: Option, +} + +impl Default for RtmpConfig { + fn default() -> Self { + Self { + bind_address: "[::]:1935".to_string().parse().unwrap(), + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct GrpcConfig { + /// The bind address for the gRPC server + pub bind_address: SocketAddr, + + /// Advertising address for the gRPC server + pub advertise_address: String, + + /// If we should use TLS for the gRPC server + pub tls: Option, +} + +impl Default for GrpcConfig { + fn default() -> Self { + Self { + bind_address: "[::]:50052".to_string().parse().unwrap(), + advertise_address: "".to_string(), + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct ApiConfig { + /// The bind address for the API server + pub addresses: Vec, + + /// Resolve interval in seconds (0 to disable) + pub resolve_interval: u64, + + /// If we should use TLS for the API server + pub tls: Option, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + addresses: vec!["localhost:50051".to_string()], + resolve_interval: 30, // 30 seconds + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RmqConfig { + /// The address of the RMQ server + pub uri: String, +} + +impl Default for RmqConfig { + fn default() -> Self { + Self { + uri: "amqp://rabbitmq:rabbitmq@localhost:5672/scuffle".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct LoggingConfig { /// The log level to use, this is a tracing env filter - pub log_level: String, + pub level: String, + + /// If we should use JSON logging + pub json: bool, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: "info".to_string(), + json: false, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct TranscoderConfig { + pub events_subject: String, +} + +impl Default for TranscoderConfig { + fn default() -> Self { + Self { + events_subject: "transcoder".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct AppConfig { + /// Name of this instance + pub name: String, /// The path to the config file. pub config_file: String, + + /// The log level to use, this is a tracing env filter + pub logging: LoggingConfig, + + /// RTMP server configuration + pub rtmp: RtmpConfig, + + /// GRPC server configuration + pub grpc: GrpcConfig, + + /// API client configuration + pub api: ApiConfig, + + /// RMQ configuration + pub rmq: RmqConfig, + + /// Transcoder configuration + pub transcoder: TranscoderConfig, } impl Default for AppConfig { fn default() -> Self { Self { - log_level: "ingest=info".to_string(), + name: "scuffle-ingest".to_string(), config_file: "config".to_string(), + logging: LoggingConfig::default(), + rtmp: RtmpConfig::default(), + grpc: GrpcConfig::default(), + api: ApiConfig::default(), + rmq: RmqConfig::default(), + transcoder: TranscoderConfig::default(), } } } diff --git a/video/ingest/src/connection_manager.rs b/video/ingest/src/connection_manager.rs new file mode 100644 index 00000000..1a09d0e7 --- /dev/null +++ b/video/ingest/src/connection_manager.rs @@ -0,0 +1,93 @@ +use std::{collections::HashMap, sync::Arc}; + +use bytes::Bytes; +use tokio::sync::{mpsc, RwLock}; +use transmuxer::MediaSegment; +use uuid::Uuid; + +pub struct StreamConnection { + connection_id: Uuid, + channel: mpsc::Sender, +} + +pub enum GrpcRequest { + Started { + id: Uuid, + }, + WatchStream { + id: Uuid, + channel: mpsc::Sender, + }, + ShuttingDown { + id: Uuid, + }, + Error { + id: Uuid, + message: String, + fatal: bool, + }, +} + +#[derive(Debug)] +pub enum WatchStreamEvent { + InitSegment(Bytes), + MediaSegment(MediaSegment), + ShuttingDown(bool), +} + +pub struct StreamManager { + streams: RwLock>>, +} + +impl StreamManager { + pub fn new() -> Self { + Self { + streams: RwLock::new(HashMap::new()), + } + } + + pub async fn register_stream( + &self, + stream_id: Uuid, + connection_id: Uuid, + channel: mpsc::Sender, + ) { + let mut streams = self.streams.write().await; + + streams.insert( + stream_id, + Arc::new(StreamConnection { + connection_id, + channel, + }), + ); + } + + pub async fn deregister_stream(&self, stream_id: Uuid, connection_id: Uuid) { + let mut streams = self.streams.write().await; + + let connection = streams.get(&stream_id); + + if let Some(connection) = connection { + if connection.connection_id == connection_id { + streams.remove(&stream_id); + } + } + } + + pub async fn submit_request(&self, stream_id: Uuid, request: GrpcRequest) -> bool { + let connections = self.streams.read().await; + + let Some(connection) = connections.get(&stream_id).cloned() else { + return false; + }; + + // We dont want to hold the lock while we wait for the channel to be ready + drop(connections); + + // We dont care if this fails since if it does fail, + // the channel will be dropped and therefore it will report + // to the caller that the stream is no longer available. + connection.channel.send(request).await.is_ok() + } +} diff --git a/video/ingest/src/global.rs b/video/ingest/src/global.rs new file mode 100644 index 00000000..2c747dc0 --- /dev/null +++ b/video/ingest/src/global.rs @@ -0,0 +1,92 @@ +use std::net::IpAddr; +use std::{net::SocketAddr, time::Duration}; + +use common::{ + context::Context, + grpc::{make_channel, TlsSettings}, +}; +use tonic::transport::{Certificate, Channel, Identity}; + +use crate::{ + config::AppConfig, connection_manager::StreamManager, + pb::scuffle::backend::api_client::ApiClient, +}; + +pub struct GlobalState { + pub config: AppConfig, + pub ctx: Context, + pub rmq: common::rmq::ConnectionPool, + pub connection_manager: StreamManager, + api_client: ApiClient, +} + +fn get_local_ip() -> IpAddr { + let interfaces = pnet::datalink::interfaces(); + + let ips = interfaces + .iter() + .filter(|i| i.is_up() && !i.ips.is_empty() && !i.is_loopback()) + .flat_map(|i| i.ips.clone()) + .map(|ip| ip.ip()) + .filter(|ip| !ip.is_loopback() && ip.is_ipv4()) + .collect::>(); + + if ips.len() > 1 { + tracing::info!("multiple ips found, using first one"); + } + + ips[0] +} + +impl GlobalState { + pub fn new(mut config: AppConfig, ctx: Context, rmq: common::rmq::ConnectionPool) -> Self { + let api_channel = make_channel( + config.api.addresses.clone(), + Duration::from_secs(config.api.resolve_interval), + if let Some(tls) = &config.api.tls { + let cert = std::fs::read(&tls.cert).expect("failed to read api cert"); + let key = std::fs::read(&tls.key).expect("failed to read api key"); + let ca = std::fs::read(&tls.ca_cert).expect("failed to read api ca"); + + let ca_cert = Certificate::from_pem(ca); + let identity = Identity::from_pem(cert, key); + + Some(TlsSettings { + ca_cert, + identity, + domain: tls.domain.clone().unwrap_or_default(), + }) + } else { + None + }, + ) + .expect("failed to create api channel"); + + let api_client = ApiClient::new(api_channel); + + if config.grpc.advertise_address.is_empty() { + // We need to figure out what our advertise address is + let port = config.grpc.bind_address.port(); + let mut advertise_address = config.grpc.bind_address.ip(); + // If the bind_address is [::] or 0.0.0.0 we need to figure out what our + // actual IP address is. + if advertise_address.is_unspecified() { + advertise_address = get_local_ip(); + } + + config.grpc.advertise_address = SocketAddr::new(advertise_address, port).to_string(); + } + + Self { + config, + ctx, + api_client, + rmq, + connection_manager: StreamManager::new(), + } + } + + pub fn api_client(&self) -> ApiClient { + self.api_client.clone() + } +} diff --git a/video/ingest/src/grpc/health.rs b/video/ingest/src/grpc/health.rs new file mode 100644 index 00000000..8f63d9ff --- /dev/null +++ b/video/ingest/src/grpc/health.rs @@ -0,0 +1,66 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status}; + +use crate::pb::health::{ + health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, +}; + +pub struct HealthServer { + global: Weak, +} + +impl HealthServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl health_server::Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + let serving = self + .global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + Ok(Response::new(HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + })) + } + + async fn watch(&self, _: Request) -> Result> { + let global = self.global.clone(); + + let output = try_stream! { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let serving = global.upgrade().map(|g| !g.ctx.is_done()).unwrap_or_default(); + + yield HealthCheckResponse { + status: if serving { ServingStatus::Serving.into() } else { ServingStatus::NotServing.into() }, + }; + } + }; + + Ok(Response::new(Box::pin(output))) + } +} diff --git a/video/ingest/src/grpc/ingest.rs b/video/ingest/src/grpc/ingest.rs new file mode 100644 index 00000000..3e9940c5 --- /dev/null +++ b/video/ingest/src/grpc/ingest.rs @@ -0,0 +1,148 @@ +use crate::{ + connection_manager::{GrpcRequest, WatchStreamEvent}, + global::GlobalState, + pb::scuffle::video::{ + ingest_server, transcoder_event_request, watch_stream_response, TranscoderEventRequest, + TranscoderEventResponse, WatchStreamRequest, WatchStreamResponse, + }, +}; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures::Stream; +use tokio::sync::mpsc; +use tonic::{async_trait, Request, Response, Status}; +use uuid::Uuid; + +pub struct IngestServer { + global: Weak, +} + +impl IngestServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl ingest_server::Ingest for IngestServer { + type WatchStreamStream = + Pin> + 'static + Send>>; + + async fn watch_stream( + &self, + request: Request, + ) -> Result> { + let global = self + .global + .upgrade() + .ok_or_else(|| Status::internal("Global state is gone"))?; + + let request = request.into_inner(); + + let request_id = Uuid::parse_str(&request.request_id) + .map_err(|_| Status::invalid_argument("Invalid request ID"))?; + let stream_id = Uuid::parse_str(&request.stream_id) + .map_err(|_| Status::invalid_argument("Invalid stream ID"))?; + + let (channel_tx, mut channel_rx) = mpsc::channel(256); + + let request = GrpcRequest::WatchStream { + id: request_id, + channel: channel_tx, + }; + + if !global + .connection_manager + .submit_request(stream_id, request) + .await + { + return Err(Status::not_found("Stream not found")); + } + + let output = try_stream! { + while let Some(event) = channel_rx.recv().await { + let event = match event { + WatchStreamEvent::InitSegment(data) => { + WatchStreamResponse { + data: Some(watch_stream_response::Data::InitSegment(data)), + } + }, + WatchStreamEvent::MediaSegment(ms) => { + WatchStreamResponse { + data: Some(watch_stream_response::Data::MediaSegment( + watch_stream_response::MediaSegment { + data: ms.data, + keyframe: ms.keyframe, + timestamp: ms.timestamp, + data_type: match ms.ty { + transmuxer::MediaType::Audio => watch_stream_response::media_segment::DataType::Audio.into(), + transmuxer::MediaType::Video => watch_stream_response::media_segment::DataType::Video.into(), + } + } + )), + } + } + WatchStreamEvent::ShuttingDown(stream_shutdown) => { + WatchStreamResponse { + data: Some(watch_stream_response::Data::ShuttingDown(stream_shutdown)), + } + } + }; + + yield event; + } + }; + + Ok(Response::new(Box::pin(output))) + } + + async fn transcoder_event( + &self, + request: Request, + ) -> Result> { + let global = self + .global + .upgrade() + .ok_or_else(|| Status::internal("Global state is gone"))?; + + let request = request.into_inner(); + + let request_id = Uuid::parse_str(&request.request_id) + .map_err(|_| Status::invalid_argument("Invalid request ID"))?; + let stream_id = Uuid::parse_str(&request.stream_id) + .map_err(|_| Status::invalid_argument("Invalid stream ID"))?; + + let request = match request.event { + Some(transcoder_event_request::Event::Started(_)) => { + GrpcRequest::Started { id: request_id } + } + Some(transcoder_event_request::Event::ShuttingDown(_)) => { + GrpcRequest::ShuttingDown { id: request_id } + } + Some(transcoder_event_request::Event::Error(error)) => GrpcRequest::Error { + id: request_id, + message: error.message, + fatal: error.fatal, + }, + None => return Err(Status::invalid_argument("Invalid event")), + }; + + if !global + .connection_manager + .submit_request(stream_id, request) + .await + { + return Err(Status::not_found("Stream not found")); + } + + Ok(Response::new(TranscoderEventResponse {})) + } +} diff --git a/video/ingest/src/grpc/mod.rs b/video/ingest/src/grpc/mod.rs new file mode 100644 index 00000000..b05ef631 --- /dev/null +++ b/video/ingest/src/grpc/mod.rs @@ -0,0 +1,57 @@ +use crate::{ + global::GlobalState, + pb::{health::health_server, scuffle::video::ingest_server}, +}; +use anyhow::Result; +use std::sync::Arc; +use tokio::select; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; + +pub mod health; +pub mod ingest; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); + tracing::info!( + "using gRPC advertise address: {}", + global.config.grpc.advertise_address + ); + + let server = if let Some(tls) = &global.config.grpc.tls { + let cert = tokio::fs::read(&tls.cert).await?; + let key = tokio::fs::read(&tls.key).await?; + let ca_cert = tokio::fs::read(&tls.ca_cert).await?; + tracing::info!("gRPC TLS enabled"); + Server::builder().tls_config( + ServerTlsConfig::new() + .identity(Identity::from_pem(cert, key)) + .client_ca_root(Certificate::from_pem(ca_cert)), + )? + } else { + tracing::info!("gRPC TLS disabled"); + Server::builder() + } + .add_service(ingest_server::IngestServer::new(ingest::IngestServer::new( + &global, + ))) + .add_service(health_server::HealthServer::new(health::HealthServer::new( + &global, + ))) + .serve_with_shutdown(global.config.grpc.bind_address, async { + global.ctx.done().await; + }); + + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = server => { + if let Err(r) = r { + tracing::error!("gRPC server failed: {:?}", r); + return Err(r.into()); + } + }, + } + + Ok(()) +} diff --git a/video/ingest/src/ingest/connection.rs b/video/ingest/src/ingest/connection.rs new file mode 100644 index 00000000..a7c3d65c --- /dev/null +++ b/video/ingest/src/ingest/connection.rs @@ -0,0 +1,1038 @@ +use bytes::Bytes; +use bytesio::bytesio::AsyncReadWrite; +use chrono::Utc; +use common::prelude::FutureTimeout; +use flv::{FlvTag, FlvTagData, FlvTagType}; +use futures::Future; +use lapin::{options::BasicPublishOptions, BasicProperties}; +use prost::Message as _; +use rtmp::{ChannelData, DataConsumer, PublishRequest, Session, SessionError}; +use std::{collections::HashMap, net::IpAddr, pin::pin, sync::Arc, time::Duration}; +use tokio::{ + select, + sync::{broadcast, mpsc}, + time::Instant, +}; +use tonic::{transport::Channel, Code}; +use transmuxer::{AudioSettings, MediaSegment, TransmuxResult, Transmuxer, VideoSettings}; +use uuid::Uuid; + +use crate::{ + connection_manager::{GrpcRequest, WatchStreamEvent}, + global::GlobalState, + ingest::variants::generate_variants, + pb::scuffle::{ + backend::{ + api_client::ApiClient, + update_live_stream_request::{self, event, update, Bitrate, Event, Update}, + AuthenticateLiveStreamRequest, LiveStreamState, NewLiveStreamRequest, + UpdateLiveStreamRequest, + }, + events::{self, transcoder_message}, + types::StreamVariant, + }, +}; + +struct Connection { + id: Uuid, + api_resp: ApiResponse, + data_reciever: DataConsumer, + transmuxer: Transmuxer, + total_video_bytes: u64, + total_audio_bytes: u64, + total_metadata_bytes: u64, + + bytes_since_keyframe: u64, + + api_client: ApiClient, + stream_id_sender: broadcast::Sender, + transcoder_req_rx: mpsc::Receiver, + + initial_segment: Option, + fragment_list: Vec, + + current_transcoder: Option>, // The current main transcoder + current_transcoder_id: Option, // The current main transcoder id + + next_transcoder: Option>, // The next transcoder to be used + next_transcoder_id: Option, // The next transcoder to be used + + last_transcoder_publish: Instant, + + report_shutdown: bool, + + transcoder_req_tx: mpsc::Sender, +} + +#[derive(Default)] +struct ApiResponse { + id: Uuid, + transcode: bool, + record: bool, + try_resume: bool, + variants: Vec, +} + +const BITRATE_UPDATE_INTERVAL: u64 = 5; +const MAX_TRANSCODER_WAIT_TIME: u64 = 60; +const MAX_BITRATE: u64 = 16000 * 1024; // 16000kbps +const MAX_BYTES_BETWEEN_KEYFRAMES: u64 = MAX_BITRATE * 4 / 8; // 4 seconds of video at max bitrate (ie. 4 seconds between keyframes) which is ~12MB + +async fn update_api( + connection_id: Uuid, + mut update_reciever: mpsc::Receiver>, + mut api_client: ApiClient, + mut stream_id: broadcast::Receiver, +) { + let Ok(stream_id) = stream_id.recv().await else { + return; + }; + + while let Some(updates) = update_reciever.recv().await { + let mut success = false; + for _ in 0..5 { + if let Err(e) = api_client + .update_live_stream(UpdateLiveStreamRequest { + connection_id: connection_id.to_string(), + stream_id: stream_id.to_string(), + updates: updates.clone(), + }) + .await + { + tracing::error!(msg = e.message(), status = ?e.code(), "api grpc error"); + tokio::time::sleep(Duration::from_secs(1)).await; + } else { + success = true; + break; + } + } + + if !success { + tracing::error!("failed to update api with bitrate after 5 retries - giving up"); + return; + } + } +} + +#[tracing::instrument(skip(global, socket))] +pub async fn handle(global: Arc, socket: S, ip: IpAddr) { + // We only need a single buffer channel for this session because the entire session is single threaded + // and we don't need to worry about buffering. + let (event_producer, mut event_reciever) = mpsc::channel(1); + let (data_producer, data_reciever) = mpsc::channel(1); + + let mut session = Session::new(socket, data_producer, event_producer); + + // When a future is pinned it becomes pausable and can be resumed later + // The entire design here is to run on a single task, and share execution on the single thread. + // So when we select on this future we are allowing this future to execute. + // This makes it so the session cannot run outside of its turn. + // Essentially this is how tokio's executor works, but we are doing it manually. + // This also has the advantage of being completely cleaned up when the function goes out of scope. + // If we used a tokio::spawn here, we would have to manually clean up the task. + let mut session_fut = pin!(session.run()); + + let event; + + select! { + _ = global.ctx.done() => { + tracing::debug!("Global context closed, closing connection"); + return; + }, + _ = &mut session_fut => { + tracing::debug!("session closed before publish request"); + return; + }, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + tracing::debug!("session timed out before publish request"); + return; + }, + e = event_reciever.recv() => { + event = e.expect("event producer closed"); + }, + }; + + let (transcoder_req_tx, transcoder_req_rx) = mpsc::channel(128); + + let mut connection = Connection { + id: Uuid::new_v4(), // Unique ID for this connection + api_resp: ApiResponse::default(), + data_reciever, + transmuxer: Transmuxer::new(), + total_audio_bytes: 0, + total_metadata_bytes: 0, + total_video_bytes: 0, + api_client: global.api_client(), + stream_id_sender: broadcast::channel(1).0, + transcoder_req_rx, + transcoder_req_tx, + current_transcoder: None, + next_transcoder: None, + initial_segment: None, + fragment_list: Vec::new(), + last_transcoder_publish: Instant::now(), + current_transcoder_id: None, + next_transcoder_id: None, + report_shutdown: true, + bytes_since_keyframe: 0, + }; + + if connection.request_api(&global, event, ip).await { + connection.run(global, session_fut).await; + } +} + +impl Connection { + #[tracing::instrument( + level = "debug", + skip(self, global, event, ip), + fields(app = %event.app_name, stream = %event.stream_name) + )] + async fn request_api( + &mut self, + global: &Arc, + event: PublishRequest, + ip: IpAddr, + ) -> bool { + let response = self + .api_client + .authenticate_live_stream(AuthenticateLiveStreamRequest { + app_name: event.app_name.clone(), + stream_key: event.stream_name.clone(), + ip_address: ip.to_string(), + ingest_address: global.config.grpc.advertise_address.clone(), + connection_id: self.id.to_string(), + }) + .await; + + let response = match response { + Ok(r) => r.into_inner(), + Err(e) => { + match e.code() { + Code::PermissionDenied => { + tracing::debug!(msg = e.message(), "api denied publish request") + } + Code::InvalidArgument => { + tracing::debug!(msg = e.message(), "api rejected publish request") + } + _ => { + tracing::error!(msg = e.message(), status = ?e.code(), "api grpc error"); + } + } + return false; + } + }; + + let Ok(id) = Uuid::parse_str(&response.stream_id) else { + tracing::error!("api responded with bad uuid: {}", response.stream_id); + return false; + }; + + if event.response.send(id).is_err() { + tracing::warn!("publish request receiver closed"); + return false; + } + + self.api_resp = ApiResponse { + id, + transcode: response.transcode, + record: response.record, + try_resume: response.try_resume, + variants: response.variants, + }; + + true + } + + #[tracing::instrument( + level = "info", + skip(self, global, session_fut), + fields(id = %self.api_resp.id, transcode = self.api_resp.transcode, record = self.api_resp.record) + )] + async fn run> + Send + Unpin>( + &mut self, + global: Arc, + session_fut: F, + ) { + tracing::info!("new publish request"); + + // At this point we have a stream that is publishing to us + // We can now poll the run future & the data receiver. + // The run future will close when the connection is closed or an error occurs + // The data receiver will never close, because the Session object is always in scope. + + let mut bitrate_update_interval = tokio::time::interval(Duration::from_secs(5)); + bitrate_update_interval.tick().await; // Skip the first tick (resolves instantly) + + let (update_channel, update_reciever) = mpsc::channel(10); + + let mut session_fut = session_fut; + + let mut api_update_fut = pin!(update_api( + self.id, + update_reciever, + self.api_client.clone(), + self.stream_id_sender.subscribe() + )); + + let mut next_timeout = Instant::now() + Duration::from_secs(2); + + let mut clean_shutdown = false; + // We need to keep track of whether the api update failed, so we can + // not poll it again if its finished. (this will panic if we poll it again) + let mut api_update_failed = false; + + while select! { + _ = global.ctx.done() => { + tracing::debug!("Global context closed, closing connection"); + + false + }, + r = &mut session_fut => { + tracing::debug!("session closed before publish request"); + match r { + Ok(clean) => clean_shutdown = clean, + Err(e) => tracing::error!("Connection error: {}", e), + } + + false + }, + data = self.data_reciever.recv() => { + next_timeout = Instant::now() + Duration::from_secs(2); + self.on_data(&update_channel, &global, data.expect("data producer closed")).await + }, + _ = bitrate_update_interval.tick() => self.on_bitrate_update(&update_channel), + _ = tokio::time::sleep_until(next_timeout) => { + tracing::debug!("session timed out during data"); + false + }, + _ = &mut api_update_fut => { + tracing::error!("api update future failed"); + api_update_failed = true; + false + } + event = self.transcoder_req_rx.recv() => self.on_transcoder_request(&update_channel, &global, event.expect("transcoder closed")).await, + } {} + + if let Some(transcoder) = self.current_transcoder.take() { + transcoder + .send(WatchStreamEvent::ShuttingDown(true)) + .await + .ok(); + } + + if let Some(transcoder) = self.next_transcoder.take() { + transcoder + .send(WatchStreamEvent::ShuttingDown(true)) + .await + .ok(); + } + + if self.initial_segment.is_none() { + self.stream_id_sender.send(self.api_resp.id).ok(); + } + + // Release the connection from the global state + // if it was never stored in the first place, this will do nothing. + global + .connection_manager + .deregister_stream(self.api_resp.id, self.id) + .await; + + if self.report_shutdown && !api_update_failed { + select! { + r = update_channel.send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::State(if clean_shutdown { + LiveStreamState::Stopped + } else { + LiveStreamState::StoppedResumable + } as i32)), + }]) => { + if r.is_err() { + tracing::error!("api update channel blocked"); + } + }, + _ = &mut api_update_fut => { + tracing::error!("api update future failed"); + } + } + } + + drop(update_channel); + + if !api_update_failed { + // Wait for the api update future to finish + if api_update_fut + .timeout(Duration::from_secs(5)) + .await + .is_err() + { + tracing::error!("api update future timed out"); + } + } + + tracing::info!(clean = clean_shutdown, "connection closed",); + } + + async fn request_transcoder( + &mut self, + update_channel: &mpsc::Sender>, + global: &Arc, + ) -> bool { + // If we already have a request pending, then we don't need to request another one. + if self.next_transcoder_id.is_some() { + return true; + } + + let request_id = Uuid::new_v4(); + self.next_transcoder_id = Some(request_id); + + let channel = match global.rmq.aquire().timeout(Duration::from_secs(1)).await { + Ok(Ok(channel)) => channel, + Ok(Err(e)) => { + tracing::error!("failed to aquire channel: {}", e); + return false; + } + Err(_) => { + tracing::error!("failed to aquire channel: timed out"); + return false; + } + }; + + if let Err(e) = channel + .basic_publish( + "", + &global.config.transcoder.events_subject, + BasicPublishOptions::default(), + events::TranscoderMessage { + id: request_id.to_string(), + timestamp: Utc::now().timestamp() as u64, + data: Some(transcoder_message::Data::NewStream( + events::TranscoderMessageNewStream { + request_id: request_id.to_string(), + stream_id: self.api_resp.id.to_string(), + ingest_address: global.config.grpc.advertise_address.clone(), + variants: self.api_resp.variants.clone(), + }, + )), + } + .encode_to_vec() + .as_slice(), + BasicProperties::default() + .with_message_id(request_id.to_string().into()) + .with_content_type("application/octet-stream".into()) + .with_expiration("60000".into()), + ) + .await + { + tracing::error!("failed to publish to jetstream: {}", e); + return false; + } + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Requested Transcoder".to_string(), + message: "Requested a transcoder to be assigned to this stream".to_string(), + level: event::Level::Info as i32, + })), + }]) + .is_err() + { + tracing::error!("failed to send update to api"); + return false; + } + + tracing::info!("requested transcoder"); + + true + } + + async fn on_transcoder_request( + &mut self, + update_channel: &mpsc::Sender>, + global: &Arc, + req: GrpcRequest, + ) -> bool { + // There are 2 possible events that happen here, either we already have a transcoder in the current_transcoder field + // Or we don't. If we do then we want to set this transcoder as the next transcoder, and when a keyframe is received + // The state will be updated to the next transcoder. + // If we don't have a transcoder, then we want to set the current transcoder and provide it with the data from the fragment list. + + let Some(init_segment) = &self.initial_segment else { + return false; + }; + + match req { + GrpcRequest::Started { id } => { + tracing::info!("transcoder started: {}", id); + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::State(LiveStreamState::Ready as i32)), + }]) + .is_err() + { + tracing::error!("api update channel blocked"); + return false; + } + } + GrpcRequest::Error { + id, + message, + fatal: _, + } => { + if self.current_transcoder_id == Some(id) || self.next_transcoder_id == Some(id) { + tracing::error!("transcoder failed: {}", message); + + // When we report a state failed we dont need to report the shutdown to the API. + // This is because the API will already know that the stream has been dropped. + self.report_shutdown = false; + + if update_channel + .try_send(vec![ + Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Transcoder Error".to_string(), + message, + level: event::Level::Error as i32, + })), + }, + Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::State(LiveStreamState::Failed as i32)), + }, + ]) + .is_err() + { + tracing::error!("api update channel blocked"); + return false; + } + + return false; + } else { + tracing::warn!("transcoder request failure id mismatch"); + } + } + GrpcRequest::ShuttingDown { id } => { + if self.current_transcoder_id == Some(id) { + tracing::info!("transcoder shutting down"); + return self.request_transcoder(update_channel, global).await; + } else if self.next_transcoder_id == Some(id) { + tracing::warn!("next transcoder shutting down"); + if let Some(transcoder) = self.next_transcoder.take() { + transcoder + .send(WatchStreamEvent::ShuttingDown(false)) + .await + .ok(); + } + self.next_transcoder = None; + self.next_transcoder_id = None; + return self.request_transcoder(update_channel, global).await; + } else { + tracing::warn!("transcoder request failure id mismatch"); + } + } + GrpcRequest::WatchStream { id, channel } => { + if self.next_transcoder_id != Some(id) { + // This is a request for a transcoder that we don't care about. + tracing::warn!("transcoder request id mismatch"); + return true; + } + + if self.next_transcoder.is_some() { + // If this happens something has gone wrong, we should never have 3 transcoders. + tracing::warn!("new transcoder set while new transcoder is already pending"); + return true; + } + + if self.current_transcoder.is_some() || self.fragment_list.is_empty() { + if channel + .send(WatchStreamEvent::InitSegment(init_segment.clone())) + .await + .is_err() + { + // It seems the transcoder has already closed. + tracing::warn!("new transcoder closed during initialization"); + return true; + } + + self.next_transcoder = Some(channel); + } else { + // We don't have a transcoder, so we can just set the current transcoder. + if channel + .send(WatchStreamEvent::InitSegment(init_segment.clone())) + .await + .is_err() + { + // It seems the transcoder has already closed. + tracing::warn!("transcoder closed during initialization"); + return self.request_transcoder(update_channel, global).await; + } + + for fragment in &self.fragment_list { + if channel + .send(WatchStreamEvent::MediaSegment(fragment.clone())) + .await + .is_err() + { + // It seems the transcoder has already closed. + tracing::warn!("transcoder closed during initialization"); + return self.request_transcoder(update_channel, global).await; + } + } + + self.fragment_list.clear(); + self.next_transcoder_id = None; + self.current_transcoder_id = Some(id); + self.current_transcoder = Some(channel); + } + } + } + + true + } + + async fn on_init_segment( + &mut self, + update_channel: &mpsc::Sender>, + global: &Arc, + video_settings: &VideoSettings, + audio_settings: &AudioSettings, + init_data: Bytes, + ) -> bool { + let variants = generate_variants(video_settings, audio_settings, self.api_resp.transcode); + + // We can now at this point decide what we want to do with the stream. + // What variants should be transcoded, ect... + if self.api_resp.try_resume { + // Check if the new variants are the same as the old ones. + let mut old_map = self + .api_resp + .variants + .iter() + .map(|v| (v.name.clone(), v)) + .collect::>(); + + for new_variant in &variants { + if let Some(old_variant) = old_map.remove(&new_variant.name) { + let video_same = if let Some(old_video) = &old_variant.video_settings { + if let Some(new_video) = &new_variant.video_settings { + old_video.codec == new_video.codec + && old_video.bitrate == new_video.bitrate + && old_video.width == new_video.width + && old_video.height == new_video.height + } else { + false + } + } else { + new_variant.video_settings.is_none() + }; + + let audio_same = if let Some(old_audio) = &old_variant.audio_settings { + if let Some(new_audio) = &new_variant.audio_settings { + old_audio.codec == new_audio.codec + && old_audio.bitrate == new_audio.bitrate + && old_audio.channels == new_audio.channels + && old_audio.sample_rate == new_audio.sample_rate + } else { + false + } + } else { + new_variant.audio_settings.is_none() + }; + + if video_same && audio_same && old_variant.metadata == new_variant.metadata { + continue; + } + } + + // If we get here, we need to start a new transcode. + tracing::info!("new variant detected, starting new transcode"); + self.api_resp.try_resume = false; + break; + } + + self.api_resp.try_resume = self.api_resp.try_resume && old_map.is_empty(); + + if !self.api_resp.try_resume { + // Report to API to get a new stream id. + // This is because the variants have changed and therefore the client player wont be able to resume. + // We need to get a new stream id so that the player can start a new session. + + let response = match self + .api_client + .new_live_stream(NewLiveStreamRequest { + old_stream_id: self.api_resp.id.to_string(), + variants: variants.clone(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(e) => { + tracing::error!("Failed to report new stream to API: {}", e); + return false; + } + }; + + let Ok(stream_id) = response.stream_id.parse() else { + tracing::error!("invalid stream id from API"); + return false; + }; + + self.api_resp.id = stream_id; + self.api_resp.variants = variants; + } + } else if let Err(e) = self + .api_client + .update_live_stream(UpdateLiveStreamRequest { + stream_id: self.api_resp.id.to_string(), + connection_id: self.id.to_string(), + updates: vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Variants( + update_live_stream_request::Variants { + variants: variants.clone(), + }, + )), + }], + }) + .await + { + tracing::error!("Failed to report new stream to API: {}", e); + return false; + } else { + self.api_resp.variants = variants; + } + + // At this point now we need to create a new job for a transcoder to pick up and start transcoding. + global + .connection_manager + .register_stream(self.api_resp.id, self.id, self.transcoder_req_tx.clone()) + .await; + + self.initial_segment = Some(init_data); + + if !self.request_transcoder(update_channel, global).await { + return false; + } + + // Respond to the rest of the session that we have a stream id and are ready to start streaming. + self.stream_id_sender.send(self.api_resp.id).is_ok() + } + + async fn on_data( + &mut self, + update_channel: &mpsc::Sender>, + global: &Arc, + data: ChannelData, + ) -> bool { + if self.bytes_since_keyframe > MAX_BYTES_BETWEEN_KEYFRAMES { + tracing::error!("keyframe interval exceeded"); + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Keyframe Interval Reached".to_string(), + level: event::Level::Error as i32, + message: "Waited too long without a keyframe, dropping stream".to_string(), + })), + }]) + .is_err() + { + tracing::error!("failed to keyframe interval reached"); + } + + return false; + } + + if (self.total_video_bytes + self.total_audio_bytes + self.total_metadata_bytes) + >= MAX_BITRATE * BITRATE_UPDATE_INTERVAL / 8 + { + tracing::error!("bitrate limit reached"); + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Bitrate Limit Reached".to_string(), + level: event::Level::Error as i32, + message: format!( + "Reached bitrate limit of {}kbps for stream", + (self.total_video_bytes + + self.total_audio_bytes + + self.total_metadata_bytes) + / 1024, + ), + })), + }]) + .is_err() + { + tracing::error!("failed to send bitrate limit reached event"); + } + + return false; + } + + match data { + ChannelData::Video { data, timestamp } => { + self.total_video_bytes += data.len() as u64; + self.bytes_since_keyframe += data.len() as u64; + + let data = match FlvTagData::demux(FlvTagType::Video as u8, data) { + Ok(data) => data, + Err(e) => { + tracing::error!(error = %e, "demux error"); + return false; + } + }; + + self.transmuxer.add_tag(FlvTag { + timestamp, + data, + stream_id: 0, + }); + } + ChannelData::Audio { data, timestamp } => { + self.total_audio_bytes += data.len() as u64; + self.bytes_since_keyframe += data.len() as u64; + + let data = match FlvTagData::demux(FlvTagType::Audio as u8, data) { + Ok(data) => data, + Err(e) => { + tracing::error!(error = %e, "demux error"); + return false; + } + }; + + self.transmuxer.add_tag(FlvTag { + timestamp, + data, + stream_id: 0, + }); + } + ChannelData::MetaData { data, timestamp } => { + self.total_metadata_bytes += data.len() as u64; + self.bytes_since_keyframe += data.len() as u64; + + let data = match FlvTagData::demux(FlvTagType::ScriptData as u8, data) { + Ok(data) => data, + Err(e) => { + tracing::error!(error = %e, "demux error"); + return false; + } + }; + + self.transmuxer.add_tag(FlvTag { + timestamp, + data, + stream_id: 0, + }); + } + } + + // We need to check if the transmuxer has any packets ready to be muxed + match self.transmuxer.mux() { + Ok(Some(TransmuxResult::InitSegment { + video_settings, + audio_settings, + data, + })) => { + if video_settings.bitrate as u64 + audio_settings.bitrate as u64 >= MAX_BITRATE { + tracing::error!("bitrate limit reached"); + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Bitrate Limit Reached".to_string(), + level: event::Level::Error as i32, + message: format!( + "Reached bitrate limit of {}kbps for stream", + video_settings.bitrate + audio_settings.bitrate + ), + })), + }]) + .is_err() + { + tracing::error!("failed to send bitrate limit reached event"); + } + + return false; + } + + self.on_init_segment( + update_channel, + global, + &video_settings, + &audio_settings, + data, + ) + .await + } + Ok(Some(TransmuxResult::MediaSegment(segment))) => { + self.on_media_segment(update_channel, global, segment).await + } + Ok(None) => true, + Err(e) => { + tracing::error!("error muxing packet: {}", e); + false + } + } + } + + pub async fn on_media_segment( + &mut self, + update_channel: &mpsc::Sender>, + global: &Arc, + segment: MediaSegment, + ) -> bool { + if segment.keyframe { + self.bytes_since_keyframe = 0; + + if let Some(transcoder) = self.next_transcoder.take() { + let Some(uuid) = self.next_transcoder_id.take() else { + tracing::error!("next transcoder id is missing"); + return false; + }; + + if transcoder + .send(WatchStreamEvent::MediaSegment(segment.clone())) + .await + .is_ok() + { + if let Some(current_transcoder) = self.current_transcoder.take() { + current_transcoder + .send(WatchStreamEvent::ShuttingDown(false)) + .await + .ok(); + } + + self.last_transcoder_publish = Instant::now(); + self.current_transcoder = Some(transcoder); + self.current_transcoder_id = Some(uuid); + + return true; + } + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "New Transcoder Disconnected".to_string(), + level: event::Level::Warning as i32, + message: format!( + "New Transcoder {} disconnected before sending first fragment", + uuid + ), + })), + }]) + .is_err() + { + tracing::error!("api update channel blocked"); + return false; + } + + tracing::error!("new transcoder disconnected before sending first fragment"); + + // The next transcoder has disconnected somehow so we need to find a new one. + if !self.request_transcoder(update_channel, global).await { + return false; + } + }; + } + + if let Some(transcoder) = &mut self.current_transcoder { + if transcoder + .send(WatchStreamEvent::MediaSegment(segment.clone())) + .await + .is_ok() + { + self.last_transcoder_publish = Instant::now(); + return true; + } + + tracing::error!("transcoder disconnected while sending fragment"); + + let current_id = self.current_transcoder_id.take().unwrap_or_default(); + + self.current_transcoder = None; + + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Event(Event { + title: "Transcoder Disconnected".to_string(), + level: event::Level::Warning as i32, + message: format!( + "Transcoder {} disconnected without graceful shutdown", + current_id + ), + })), + }]) + .is_err() + { + tracing::error!("api update channel blocked"); + return false; + } + + // This means the current transcoder has disconnected so we need to find a new one. + if !self.request_transcoder(update_channel, global).await { + return false; + } + } + + if Instant::now() - self.last_transcoder_publish + >= Duration::from_secs(MAX_TRANSCODER_WAIT_TIME) + { + tracing::error!("no transcoder available to publish to"); + return false; + } + + if segment.keyframe { + self.fragment_list.clear(); + self.fragment_list.push(segment); + } else if self + .fragment_list + .first() + .map(|f| f.keyframe) + .unwrap_or_default() + { + self.fragment_list.push(segment); + } + + true + } + + fn on_bitrate_update(&mut self, update_channel: &mpsc::Sender>) -> bool { + let video_bitrate = (self.total_video_bytes * 8) / BITRATE_UPDATE_INTERVAL; + let audio_bitrate = (self.total_audio_bytes * 8) / BITRATE_UPDATE_INTERVAL; + let metadata_bitrate = (self.total_metadata_bytes * 8) / BITRATE_UPDATE_INTERVAL; + + self.total_video_bytes = 0; + self.total_audio_bytes = 0; + self.total_metadata_bytes = 0; + + // We need to make sure that the update future is still running + if update_channel + .try_send(vec![Update { + timestamp: Utc::now().timestamp() as u64, + update: Some(update::Update::Bitrate(Bitrate { + video_bitrate, + audio_bitrate, + metadata_bitrate, + })), + }]) + .is_err() + { + tracing::error!("api update channel blocked"); + return false; + } + + true + } +} diff --git a/video/ingest/src/ingest/mod.rs b/video/ingest/src/ingest/mod.rs index bbbaf37d..178c4785 100644 --- a/video/ingest/src/ingest/mod.rs +++ b/video/ingest/src/ingest/mod.rs @@ -1,9 +1,61 @@ -use std::sync::Arc; - use anyhow::Result; +use common::prelude::FutureTimeout; +use std::{sync::Arc, time::Duration}; +use tokio::{net::TcpSocket, select}; + +use crate::global::GlobalState; + +mod connection; +mod variants; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("Listening on {}", global.config.rtmp.bind_address); + let socket = if global.config.rtmp.bind_address.is_ipv6() { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + + socket.set_reuseaddr(true)?; + socket.set_reuseport(true)?; + socket.bind(global.config.rtmp.bind_address)?; + let listener = socket.listen(1024)?; + let tls_acceptor = if let Some(tls) = &global.config.rtmp.tls { + tracing::info!("TLS enabled"); + let cert = std::fs::read(&tls.cert).expect("failed to read rtmp cert"); + let key = std::fs::read(&tls.key).expect("failed to read rtmp key"); + + Some(Arc::new(tokio_native_tls::TlsAcceptor::from( + native_tls::TlsAcceptor::new(native_tls::Identity::from_pkcs8(&cert, &key)?)?, + ))) + } else { + None + }; + + loop { + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = listener.accept() => { + let (socket, addr) = r?; + tracing::debug!("Accepted connection from {}", addr); -use crate::config::AppConfig; + let tls_acceptor = tls_acceptor.clone(); + let global = global.clone(); -pub async fn run(_config: Arc) -> Result<()> { - todo!() + tokio::spawn(async move { + if let Some(tls_acceptor) = tls_acceptor { + let Ok(Ok(socket)) = tls_acceptor.accept(socket).timeout(Duration::from_secs(5)).await else { + return; + }; + tracing::debug!("TLS handshake complete"); + connection::handle(global, socket, addr.ip()).await; + } else { + connection::handle(global, socket, addr.ip()).await; + } + }); + }, + } + } } diff --git a/video/ingest/src/ingest/variants.rs b/video/ingest/src/ingest/variants.rs new file mode 100644 index 00000000..2921d404 --- /dev/null +++ b/video/ingest/src/ingest/variants.rs @@ -0,0 +1,115 @@ +use mp4::codec::{AudioCodec, VideoCodec}; +use serde_json::json; +use transmuxer::{AudioSettings, VideoSettings}; +use uuid::Uuid; + +use crate::pb::scuffle::types::{stream_variant, StreamVariant}; + +pub fn generate_variants( + video_settings: &VideoSettings, + audio_settings: &AudioSettings, + transcode: bool, +) -> Vec { + let mut variants = Vec::new(); + + let audio_settings = stream_variant::AudioSettings { + channels: audio_settings.channels as u32, + bitrate: audio_settings.bitrate, + sample_rate: audio_settings.sample_rate, + codec: AudioCodec::Opus.to_string(), + }; + + variants.push(StreamVariant { + id: Uuid::new_v4().to_string(), + name: "source".to_string(), + video_settings: Some(stream_variant::VideoSettings { + bitrate: video_settings.bitrate, + codec: video_settings.codec.to_string(), + framerate: video_settings.framerate as u32, + height: video_settings.height, + width: video_settings.width, + }), + audio_settings: Some(audio_settings.clone()), + metadata: json!({}).to_string(), + }); + + variants.push(StreamVariant { + id: Uuid::new_v4().to_string(), + name: "audio".to_string(), + video_settings: None, + audio_settings: Some(audio_settings.clone()), + metadata: json!({}).to_string(), + }); + + if transcode { + let aspect_ratio = video_settings.width as f64 / video_settings.height as f64; + + struct Resolution { + side: u32, + framerate: u32, + bitrate: u32, + } + + let resolutions = [ + Resolution { + bitrate: 4000 * 1024, + framerate: video_settings.framerate.min(60.0) as u32, + side: 720, + }, + Resolution { + bitrate: 2000 * 1024, + framerate: video_settings.framerate.min(30.0) as u32, + side: 480, + }, + Resolution { + bitrate: 1000 * 1024, + framerate: video_settings.framerate.min(30.0) as u32, + side: 360, + }, + ]; + + for res in resolutions { + // This prevents us from upscaling the video + // We only want to downscale the video + let (width, height) = if aspect_ratio > 1.0 && video_settings.height > res.side { + ((res.side as f64 * aspect_ratio).round() as u32, res.side) + } else if aspect_ratio < 1.0 && video_settings.width > res.side { + (res.side, (res.side as f64 / aspect_ratio).round() as u32) + } else { + continue; + }; + + // We dont want to transcode video with resolutions less than 100px on either side + // We also do not want to transcode anything more expensive than 720p on a 16:9 aspect ratio (720 * 1280) + // This prevents us from transcoding a "720p" with an aspect ratio of 4:1 (720 * 2880) which is extremely expensive. + // Just some insight, 2880 / 1280 = 2.25, so this video is 2.25 times more expensive than a normal 720p video. + // 1080 * 1920 = 2073600 + // 720 * 2880 = 2073600 + // So a 720p video with an aspect ratio of 4:1 is just as expensive as a 1080p video with a 16:9 aspect ratio. + if width < 100 || height < 100 || width * height > 720 * 1280 { + continue; + } + + variants.push(StreamVariant { + id: Uuid::new_v4().to_string(), + name: format!("{}p", res.side), + video_settings: Some(stream_variant::VideoSettings { + width, + height, + bitrate: res.bitrate, + framerate: res.framerate, + codec: VideoCodec::Avc { + profile: 100, // High + level: 51, // 5.1 + constraint_set: 0, + } + .to_string(), + }), + audio_settings: Some(audio_settings.clone()), + metadata: json!({}).to_string(), + }); + } + } + + variants +} diff --git a/video/ingest/src/main.rs b/video/ingest/src/main.rs index 632b44a3..1c8f4984 100644 --- a/video/ingest/src/main.rs +++ b/video/ingest/src/main.rs @@ -1,24 +1,68 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use anyhow::Result; -use common::logging; -use tokio::select; +use anyhow::{Context as _, Result}; +use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use tokio::{select, signal::unix::SignalKind, time}; mod config; +mod connection_manager; +mod global; +mod grpc; mod ingest; +mod pb; #[tokio::main] async fn main() -> Result<()> { - let config = Arc::new(config::AppConfig::parse()?); + let config = config::AppConfig::parse()?; - logging::init(&config.log_level)?; + logging::init(&config.logging.level, config.logging.json)?; - tracing::info!("starting"); + tracing::info!("starting: loaded config from {}", config.config_file); + + let (ctx, handler) = Context::new(); + + let rmq = common::rmq::ConnectionPool::connect( + config.rmq.uri.clone(), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .context("failed to connect to rabbitmq, timedout")? + .context("failed to connect to rabbitmq")?; + + let global = Arc::new(global::GlobalState::new(config, ctx, rmq)); + + let ingest_future = tokio::spawn(ingest::run(global.clone())); + let grpc_future = tokio::spawn(grpc::run(global.clone())); + + // Listen on both sigint and sigterm and cancel the context when either is received + let mut signal_handler = signal::SignalHandler::new() + .with_signal(SignalKind::interrupt()) + .with_signal(SignalKind::terminate()); select! { - _ = ingest::run(config.clone()) => tracing::info!("ingest stopped"), - _ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received"), + r = ingest_future => tracing::error!("api stopped unexpectedly: {:?}", r), + r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), + r = global.rmq.handle_reconnects() => tracing::error!("rmq stopped unexpectedly: {:?}", r), + _ = signal_handler.recv() => tracing::info!("shutting down"), + } + + // We cannot have a context in scope when we cancel the handler, otherwise it will deadlock. + drop(global); + + // Cancel the context + tracing::info!("waiting for tasks to finish"); + + select! { + _ = time::sleep(Duration::from_secs(60)) => tracing::warn!("force shutting down"), + _ = signal_handler.recv() => tracing::warn!("force shutting down"), + _ = handler.cancel() => tracing::info!("shutting down"), } Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/video/ingest/src/pb.rs b/video/ingest/src/pb.rs new file mode 100644 index 00000000..c76f275b --- /dev/null +++ b/video/ingest/src/pb.rs @@ -0,0 +1,21 @@ +pub mod scuffle { + pub mod backend { + tonic::include_proto!("scuffle.backend"); + } + + pub mod types { + tonic::include_proto!("scuffle.types"); + } + + pub mod video { + tonic::include_proto!("scuffle.video"); + } + + pub mod events { + tonic::include_proto!("scuffle.events"); + } +} + +pub mod health { + tonic::include_proto!("grpc.health.v1"); +} diff --git a/video/ingest/src/tests/certs/ca.ini b/video/ingest/src/tests/certs/ca.ini new file mode 100644 index 00000000..bf362da6 --- /dev/null +++ b/video/ingest/src/tests/certs/ca.ini @@ -0,0 +1,13 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +# Since this is a CA, the key usage is critical +x509_extensions = v3_ca + +[v3_ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[dn] +CN = 127.0.0.1 diff --git a/video/ingest/src/tests/certs/client.ini b/video/ingest/src/tests/certs/client.ini new file mode 100644 index 00000000..01771e66 --- /dev/null +++ b/video/ingest/src/tests/certs/client.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_client + +[v3_client] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/ingest/src/tests/certs/ec/ca.crt b/video/ingest/src/tests/certs/ec/ca.crt new file mode 100644 index 00000000..70836fd6 --- /dev/null +++ b/video/ingest/src/tests/certs/ec/ca.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBazCCARKgAwIBAgIUKg0YJcXWymDGf2vrJmvjeEfT5eowCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUwNDEz +MjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEe7MLNb0XxhhhExFPY8wAbeMds+pTM2ece7jJspRqM9FuP5qEWuPc3WAw +ZuwrN3IAdqY8aQUrDGFVMTwYj7oB5KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFJkcukfoOigHknCiuEo2Pa8byAiAMAoGCCqG +SM49BAMCA0cAMEQCIBmR6ve/TWAbyrYQdxJ3lnFD7aL6AFpQ0hVaITezxfbNAiBD +t8xugEACCBMvDrpCpT/Umr39qe7a00/34GYCvqgwDA== +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/ec/ca.key b/video/ingest/src/tests/certs/ec/ca.key new file mode 100644 index 00000000..4c70bafe --- /dev/null +++ b/video/ingest/src/tests/certs/ec/ca.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgtmYmtWFgm0kbJgSU +w0h2+S8xiqv4p6IL3li9orgk7EGhRANCAAR7sws1vRfGGGETEU9jzABt4x2z6lMz +Z5x7uMmylGoz0W4/moRa49zdYDBm7Cs3cgB2pjxpBSsMYVUxPBiPugHk +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/ec/client.crt b/video/ingest/src/tests/certs/ec/client.crt new file mode 100644 index 00000000..93fec3d7 --- /dev/null +++ b/video/ingest/src/tests/certs/ec/client.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV2gAwIBAgIUO94EQCwFAI0j0eVtz8EN1M8rjmEwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUwNDEz +MjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEcUi9Sekfj13HdEGDA1mIIejBqiP7qZAzTYaTY9/SRStWZ6euUd70dkGv +u2X0zb1ISm+e3+xPm8YvOhCPZ6T/LaOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFDZIrawxn4PRaaBfOd/rBlEn1StGMB8GA1UdIwQYMBaA +FJkcukfoOigHknCiuEo2Pa8byAiAMAoGCCqGSM49BAMCA0gAMEUCIEfX9vpWo4pX +Qr/45WLHMF5g5WhFOm8qkToNfnhJylmjAiEAgWGtizFjlbNR44UooRcMZKux6veh +p/ZdbJD8J+2y0O0= +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/ec/client.key b/video/ingest/src/tests/certs/ec/client.key new file mode 100644 index 00000000..92ade85f --- /dev/null +++ b/video/ingest/src/tests/certs/ec/client.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgLlLoZpde1qkNElax +iSRw67cH8FnzOI9oOqSBLFCdSiehRANCAARxSL1J6R+PXcd0QYMDWYgh6MGqI/up +kDNNhpNj39JFK1Znp65R3vR2Qa+7ZfTNvUhKb57f7E+bxi86EI9npP8t +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/ec/server.crt b/video/ingest/src/tests/certs/ec/server.crt new file mode 100644 index 00000000..9b1e4f21 --- /dev/null +++ b/video/ingest/src/tests/certs/ec/server.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBuDCCAV2gAwIBAgIUbXgWeiS+wCTB/ctrusPDDGJpCbQwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUwNDEz +MjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE91sbyK0PA+L5aj7+VJXorZ0ecDTgMQXIkYCcmJVhw994113du6jHB8qD +m/J6loL1JPKhBR+nh0VRwlGm2VzgUaOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFD18kkPLwPxTRgiYEgwR4rGp2uDhMB8GA1UdIwQYMBaA +FJkcukfoOigHknCiuEo2Pa8byAiAMAoGCCqGSM49BAMCA0kAMEYCIQC+6GVgIWks +byOXHwiib3wHhGsoxUrkWRRi24rMvrWpkwIhANFBCfEo7389/VFPgPb/tzT22p2W +vRF1f2AuIlW/xGmi +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/ec/server.key b/video/ingest/src/tests/certs/ec/server.key new file mode 100644 index 00000000..0a5d7c7c --- /dev/null +++ b/video/ingest/src/tests/certs/ec/server.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwdSva7zVNBIu18zw +Y+3NHzB+Ttm6OV+cLLX/YVt5MlShRANCAAT3WxvIrQ8D4vlqPv5UleitnR5wNOAx +BciRgJyYlWHD33jXXd27qMcHyoOb8nqWgvUk8qEFH6eHRVHCUabZXOBR +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/generate.sh b/video/ingest/src/tests/certs/generate.sh new file mode 100755 index 00000000..bfcdc1b1 --- /dev/null +++ b/video/ingest/src/tests/certs/generate.sh @@ -0,0 +1,25 @@ +mkdir -p rsa ec + +openssl genrsa -out rsa/ca.key 2048 +openssl genrsa -out rsa/server.key 2048 +openssl genrsa -out rsa/client.key 2048 + +openssl req -x509 -sha256 -days 365 -nodes -key rsa/ca.key -config ca.ini -out rsa/ca.crt +openssl req -x509 -sha256 -days 365 -CA rsa/ca.crt -CAkey rsa/ca.key -nodes -key rsa/server.key -config server.ini -out rsa/server.crt +openssl req -x509 -sha256 -days 365 -CA rsa/ca.crt -CAkey rsa/ca.key -nodes -key rsa/client.key -config client.ini -out rsa/client.crt + +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out ec/ca.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out ec/server.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out ec/client.key + +openssl pkcs8 -topk8 -nocrypt -in ec/ca.key -out ec/ca.key.pem +openssl pkcs8 -topk8 -nocrypt -in ec/server.key -out ec/server.key.pem +openssl pkcs8 -topk8 -nocrypt -in ec/client.key -out ec/client.key.pem + +mv ec/ca.key.pem ec/ca.key +mv ec/server.key.pem ec/server.key +mv ec/client.key.pem ec/client.key + +openssl req -x509 -sha256 -days 365 -nodes -key ec/ca.key -config ca.ini -out ec/ca.crt +openssl req -x509 -sha256 -days 365 -CA ec/ca.crt -CAkey ec/ca.key -nodes -key ec/server.key -config server.ini -out ec/server.crt +openssl req -x509 -sha256 -days 365 -CA ec/ca.crt -CAkey ec/ca.key -nodes -key ec/client.key -config client.ini -out ec/client.crt diff --git a/video/ingest/src/tests/certs/rsa/ca.crt b/video/ingest/src/tests/certs/rsa/ca.crt new file mode 100644 index 00000000..14deda2b --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+DCCAeCgAwIBAgIUCVCgv0yc7VnYYdgpRsuB1HMR6E4wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUw +NDEzMjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAuEdhiUPdIFmqaAVSvWOt0x0Yw+/HWbKCRC+h9Z9NSuiR +O9rkbbYZo7vk86fACC1QNFPe7jkuxgySp8pmppjsKhQ6FxgzaN504AmowIzx3DjD +eSP40/7dNA88KJYnlTG60NqNCdThYUH9Ut0ZiEGOiMKuM6J/UzvjdSJs+yEq+/51 +Io48EO1QLVaF6y2OLwhSiEczsx1thrBxwSuVuFHrEZRh1nDG2X5PfV1jXHBnwWBd +1uHmu3kYIfCMwpC7GaXNv1GKfBxN1DZu69JUVhCVrNfVvZpZdMryu3yf4X5t843G +iULgD0l04HVFWK0/kc6OmB0SJtn9IRPX2f/uwopyNQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUXK70X0hFAt2bZiuH +ZS3oLcd5u14wDQYJKoZIhvcNAQELBQADggEBAIYrbGs4DpwjqQbXVgaWk4n2qo86 +QpvCSeErscOI24v/hx0Yiw5/3KF/qevjd+QZ4JzTZYktuigWseajiSzvl5BhanpS +NtgUpgamjCnwH0fmIODMKOgmKjlMsSkDfPLaAnD2vfSlwuyu43ug13i0XcJhLCmo +vbVllHl+nQkQACpVkJwxsOHpkpgVHf/JW4udpPvTSbTua9yNvIT15KW4oHwT6l5c +cNky3AL8ij0OZwbEdpTBq9uZkDQiVKd31Rjsgipm1/uuZje4yNxQ0fTYHIPQvbG7 +obrep5RjVe34U18IatHb4z/hXX58BU4Ks7MeAC7RWlHh+7Dqz9/DncSoFeA= +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/rsa/ca.key b/video/ingest/src/tests/certs/rsa/ca.key new file mode 100644 index 00000000..c4a98b7c --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4R2GJQ90gWapo +BVK9Y63THRjD78dZsoJEL6H1n01K6JE72uRtthmju+Tzp8AILVA0U97uOS7GDJKn +ymammOwqFDoXGDNo3nTgCajAjPHcOMN5I/jT/t00DzwolieVMbrQ2o0J1OFhQf1S +3RmIQY6Iwq4zon9TO+N1Imz7ISr7/nUijjwQ7VAtVoXrLY4vCFKIRzOzHW2GsHHB +K5W4UesRlGHWcMbZfk99XWNccGfBYF3W4ea7eRgh8IzCkLsZpc2/UYp8HE3UNm7r +0lRWEJWs19W9mll0yvK7fJ/hfm3zjcaJQuAPSXTgdUVYrT+Rzo6YHRIm2f0hE9fZ +/+7CinI1AgMBAAECggEACCBdDgKq4UwVo8gw+vlGwgOrYv/zidesKcnISeQNnR3O +l8s3ncSfx/9szDeWfusmpA7FyuTFKgaFv3YBcFvX4E7WNUyufwj+XPSNbKOv38Rg +ubp1yj2Cmj2IwN5xnkFG6dfAIVHyDXStm/D0Tl9buS63+WlzVXjo4d0T3WrmnHMB +SyY5fiQCYl7/NTtVbTBa5SEl7JcPphMa3bo0jdhGE6DTj5Fo7Fk2lOj0VZKUHv8F +2fd/LgoBQUkzIGtcQaDXK/ryi9EIJpSsSlGLt9vrOVrnI8+2CUY8scZuw5P9fueg +iGP9NVLQdEFIfj8z+kU9yp3M7wO+d6jucVFd92C56QKBgQDPU1VDosjSEG6BKMGR +NP+x8MtRW5Rk3WRnV8tVKnCVLvUTLgwc46C20EJawmp5Qr3Ebf50cUd3zzGuYnrn +DsuU9QI96qoZrS9CR0xQ61nRJ5vAc00jm6r/IdNyP7nln/MXI9GtQAKFPfmMq/vq +tdfLtV3F63uj7rWCtP1DaQF/6QKBgQDjiuWt1jNEsARCxhviW1WtD4EbBMRh8a4p +Yn9XLJ0k6O1m951gknm8xqC6ntQzG2gktUdoy0Birv1W1PdRyc/xOEFFk5ocgbgA +93otbScIrBwKTWDOoCqmdFX2YURpXOMCAle+1vtAHfw/1A5ToPzrXTMBuVlFgP24 +kwUcDy2cbQKBgDHqq3W8ZMOG50Q7rtcqPoH2Ks2s0f6y+zCSh8c6j56rjqAOjyYJ +fDFn1QILGx2U/yGjJgedGorzHNASr+qfuk5j6yVDMa867kzz46D7+UUNV0evuxve +p/4Dg+hXBYgOybWBj0M6TSENed7vrZimLY3DXg+AEeW3XIZa0zt+tbi5AoGAOIi7 +FhzhMhC1jk2ggfYFbHEst6TQkjE0/21MjEE2bgQ9b9bX0DW0b3W+W3+441XM40nz +CxNs1Nf7c8aICFcnBhzfAsVwheKo7/yM65pyF1KDyP9+rPfYgPDopsV19OOyNz6T +xIOveze4A7fRM3ANRwEp+iSuDHnHEEPfAiC3SWUCgYEAnA+RouRVL/PegENTsWIw +RhraNHfzDuKzCWQPPWjT3oorfzcZoS+lXMIepbPxzkMPAesklHbiwkXy7wkcOLuV +WnvW8myhZEXrqd/wyjlcGtAlKaNCTibbRrzacifqmZKGe+5SkeYJh/jVTFTrLNow +4joRXu16lII9lefqubf9JfA= +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/rsa/client.crt b/video/ingest/src/tests/certs/rsa/client.crt new file mode 100644 index 00000000..b033be36 --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/client.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUfnJ28ki/A2vJ3XmrZxuoqhvwBagwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUw +NDEzMjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEArAIU0S7sZEUh3KeFwfiEJtNHfUPoHkc+HWU2D9mmXdGA +1ThsVIPkLK1YkUhCop6oyaankTR0PN3SGTTwo7H/fomkqbClIgDf7He6HqBw+zu7 +ns1V8CZ3LGF+gbKgsKuPmHhkHmFP63H31W7NOiDIaqestQs1b4Fpoxto2d1l/13I +ubZGPc+R64DNzYO9az7ubEN0+cZL3u2UmIniYEVVHdUsMeDq6kUgZ7fZYHWW2sJ0 +hH7HVC+Ggz8tb4Ygew0q1O648Bacj0kn3AW6UWQxC3o9W7DWXDwxPtvfR4WP3Pbx +sYRCLuvcyEqfk4atySTwgzH63M/sp/XKIEDlrOD7nwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUwU9lNuJrlz5jJM8eiAEMlzUA +OukwHwYDVR0jBBgwFoAUXK70X0hFAt2bZiuHZS3oLcd5u14wDQYJKoZIhvcNAQEL +BQADggEBAH5FBLngAr2xtBRfNFxhQnT+SuRuPGA4kMRopkUZBWY8o+3MkN4idxbK +jZoLYDMzE4c3RBR7mN7oYPD5xvNd/9ZI0GB7LQpqQWIsv2cHR6J1TAwkeQ4+ERtF +keOctgtjp0ZXhZyKI4qQIapfi6lPLHWb+WWa/H9UeCy5yW93nmLRHYNdlz00fE9k +Vs/0I8kn6IaSPO3yy26og8ABzoP27yJ7GQEtqA/7MoR7I82SHgeWGPm2imc70N2j +GsLyS1rRem2HEwpt2PQeRzqQK1OFPm4iSp/ZOKijC5O4FikggAhpzCQipHB7eQf9 +KMam7bBeuluDt2JbIz7V0Vdg7zIf6xs= +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/rsa/client.key b/video/ingest/src/tests/certs/rsa/client.key new file mode 100644 index 00000000..532e7984 --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCsAhTRLuxkRSHc +p4XB+IQm00d9Q+geRz4dZTYP2aZd0YDVOGxUg+QsrViRSEKinqjJpqeRNHQ83dIZ +NPCjsf9+iaSpsKUiAN/sd7oeoHD7O7uezVXwJncsYX6BsqCwq4+YeGQeYU/rcffV +bs06IMhqp6y1CzVvgWmjG2jZ3WX/Xci5tkY9z5HrgM3Ng71rPu5sQ3T5xkve7ZSY +ieJgRVUd1Swx4OrqRSBnt9lgdZbawnSEfsdUL4aDPy1vhiB7DSrU7rjwFpyPSSfc +BbpRZDELej1bsNZcPDE+299HhY/c9vGxhEIu69zISp+Thq3JJPCDMfrcz+yn9cog +QOWs4PufAgMBAAECggEARmypukW0eo9FgHLEVHkcHktV00dI05DGsUFdo5qwv2vG +DEaIMAg6FUK55u3Hfo5oO/u0UsK/rqYBXdmrhtIbceMIHIPa8HOcOtSASwbUi0BX +HnsiqusM0wptwctxeDQd3Ea6cyfTkWn9lxDBDQIcHHLfRta6f5dkOWhBAyZfGvDO +I7TaIARakA2LEvltub0w3T/nU7Aq1F31LxY6Sk0R4Qne4dkFCO0q+ry+ZBbqrHp0 +ZqbvCNelKSp26mfp/b1B/HYB06UeMRNBezcvqpAdl/gdAcf6TNZYnQqYdMbVUNAW ++rYe1x3JfNUtsBAPHANwhFPbdRkXsadiKVZLhYVubQKBgQDCN8cGuzayEUNCiOd6 +Fr0T70m2cdMZgV67oVv23OQCE8poe0GFLF2wkFaqldNaSiPwDpQd+4mAanxeQ2OH +IPYPonZX38CDmA0Uzka0VxQu8a2xpk5/fV4OTqtr5TwnegGePMvBE1Y4H6VER/c1 +Wf8zzz+iVqx/BPWPFxQsoxcMSwKBgQDiuaSCEmYrdpPbc9AWBRY0gn4GYE3FWnuw +erLGY+NEPZjx8MsEu5kZbrFHvfJMox4KBjE1TO0VKwLV/saSGrRWzh8dKVLoYMiZ +MOWtshKaXAh6/omQpVdCsM8AmfLzB7jHM1GICWr7KbI6DtTri+IpB6cl9rBuim2c +gsB5yQ0RfQKBgQCunIkiYyS8mUqYQg+is1jQ2Fy0W8eH8cji9iKBYnzX5UUgBFiW +y5l8CXjxqvw2+9stk13VSvWHb7Va5klQDvFKgyzUukURyp1QLFhjALsTrZMU2clP +U75BLLdvrMwVTdqwkkY50xG8Ka9jITR/UedghB7Y7AXvuNyxBfXMX+Bz2wKBgQCE +I5ZRiM+rPSznlhQNAWHzsKoqpS4ue5HjRV+0aLsoJo5hU+m/FGcif3UrTvVM3TTA +uuMrIW58C9lXR2oL9Sxt+yv/HvqHHQFM50a1eTeGZ2U56efbOcIlyE6dFxyVsEZt +Muet1W9YevC5DbPipGBncWJlqeUiR+OieEIduKO5MQKBgQChT48ooTi/ZmmOiSFq +mPkz6FU4Cc1srQgaPkocq5yCqzmtmT897xEB1CHEGcTkhJAMrHK0gbCIpV5mLcyh ++44XVE3qhadMEBxnnXE/722BPvkyFNqwsVzYQ9EHUfN9AoCMd2Svlo6t9D0zWOH6 +bvlZTqYMaRF7phe1conjIQ+eig== +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/rsa/server.crt b/video/ingest/src/tests/certs/rsa/server.crt new file mode 100644 index 00000000..2c953e0a --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUN75BMa/lDnV4f8vGNYdvSnjYZScwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDUwNTEzMjU1NFoXDTI0MDUw +NDEzMjU1NFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA29A+pYFWiA2EnA5+DLmSdF6grmDVvHr5Lc5Oaiiwxxow +YUXXYCdFnqxOom5yEOFUN3lW+vHbvbftNnqm2E/PPr4hbFVbO1vMpZloSa4Fmal3 +pwXHQWxDDSpqCLHXvF8eSNdAv/8ehh/abbBt9iipUMr6wIJd03+beNmb5s3Hh4c4 +eAJE2Q8Sh9NxPQvPB1QcZ9rSFISdAa9soaOGdddGLHWzDKVQ3P2HbwX8ASh6ELfV +6Suem0DIN6SH84JHf0DaH/l+P7AZYOIc30zLAt3DSNA/H1LqCPCkXB8HEzk9g4Pc +F3YJIi5y3d7lSnycwffD1MD+UCdQQJ0ZXCZDWmFzOQIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUoAmpcUEY6T2tzt01tQH3k5iT +7uEwHwYDVR0jBBgwFoAUXK70X0hFAt2bZiuHZS3oLcd5u14wDQYJKoZIhvcNAQEL +BQADggEBACsfOIjpuCftrRLZpe4O70MVzPqPHb1jZMFzn0Vkch5C502/DIQoePl0 +ijaEvfgkDFglcE+Q/PJZ+GiYFRORbDuaBThIIJpoyEYvUqoMqaf9KyQ1zsgYsbhB +RleCp0J1q56vbwQiGvSLe5WHXN/7hqUUcOKHNzNBdz/t9z4/lb7vFOu5vIdLJyWb +uYIQQYTjedy0IHw/qLweiJJA+KNnB0deTttu9OVMsa8ZWbdrN4xoAJZrXtoNryFC +2eyKiy+ugLDXZUqFYugn+roE4AqQqnC0/JKl6bBawEnKcXQVdptmvoDMG9fL0GIp +cwE4Q8z6DMFw9uA3exXGLtOGi5KIAiQ= +-----END CERTIFICATE----- diff --git a/video/ingest/src/tests/certs/rsa/server.key b/video/ingest/src/tests/certs/rsa/server.key new file mode 100644 index 00000000..bc41f5cd --- /dev/null +++ b/video/ingest/src/tests/certs/rsa/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDb0D6lgVaIDYSc +Dn4MuZJ0XqCuYNW8evktzk5qKLDHGjBhRddgJ0WerE6ibnIQ4VQ3eVb68du9t+02 +eqbYT88+viFsVVs7W8ylmWhJrgWZqXenBcdBbEMNKmoIsde8Xx5I10C//x6GH9pt +sG32KKlQyvrAgl3Tf5t42ZvmzceHhzh4AkTZDxKH03E9C88HVBxn2tIUhJ0Br2yh +o4Z110YsdbMMpVDc/YdvBfwBKHoQt9XpK56bQMg3pIfzgkd/QNof+X4/sBlg4hzf +TMsC3cNI0D8fUuoI8KRcHwcTOT2Dg9wXdgkiLnLd3uVKfJzB98PUwP5QJ1BAnRlc +JkNaYXM5AgMBAAECggEAAK7bFsDUKHBb3SBulbuvenYgkveMvzTt9G/KGJhqTTHi +Y1rScFyheFFXGGGlEzZ1YqhrYHHUrPPaFlurReRMx1a6MjSVbUEI/GG++NRmWF89 +Q6ve8Vzrq3ll+v/by70VLh/XVOU62smQli5v6KQvbfe5TW3M+uLPTawhYErDhbO1 +cufsNoSKeGshfQ80e2cULmXccN/RcxfFHTYImRuaI6h3tb39QWC7VZtiLHrRVz0W +2z5KlgpUilmub1kOjN0HQ+l7d4wZVQROMFJGYJz5sDDDmBFrcxdADRw95mfovQ5i +P2aHETX/xwDNqxlA/noit1o3XidyDsgV0lvj84saVQKBgQDlZZD1ZlvXIYzwy0DS +q0yp7O0goMFJECRSUda3DXmaO/iOD+xbJlPpmPEeObYzSBhwUgMXSwDmk3QrTSC7 +8RXOO0qETCIdKtJUmP0OXwhZeH/tWaq/p2AjUvOCNA+xtQEumJU9Hn9rDpjCqCsi +3AZ+M5aDD6wtywcxI+AC0PIUNQKBgQD1Tipfxa09h+4rrdvwmtn8C8yvRCeYL7J1 +cZ9OzqSCvlc5zUzSaFGDNMscViMfrTEVHeJk+0VO9EMiVvtkiQTQOIkbFeeTjwXc +cPKkv/MCiE7Iy9UG5jPIdnKZI/C6n7dGcRJKAPQMU36swRK7D5God9VGjO7/vI0W +mCqhg347dQKBgH2UNV/yEhLaXhKv4iOx/P0FI+b5ufYqbQfXn2iEau6BKBd1Jhnc +CJHX1R+Lzm+SZzaN3v1QZQTU8gKGbYYVe69zi96S4xX+jDgdrGLCqYp5SecAcFYM +5bdQwNelcnYBNc1xYDmeSXis+/mMIFksBC3wRdQnr0U+YQiIsCg6hFuFAoGAWtuS +0lpTamD4lHEW761LZBGyxJAH4lR4Uwu8p+HeCRcbE6u48o308xYQzPQSml75uQqS +mjh/WVkbrJJvFrOxdrYaAlBAr+TflOC43tDB34RBOhpVUvLc5zkIBTTMqzMRHSQw +U/y6Z/5dAvuYRnTjzHRmKHV26H4b++xsjdo4XSkCgYBD0s8gQmX1Zan6pvI8Sjss +/Liax4g/xBOSAh46xbrtfj9ovK3puSpMYz5PwgYgxdpMkqzU7XNK2mgxigjXnQwb +jrX8IIKyK80ggjk++7Xzku9qdMxkv+XZROEz2pRW5JXExT8bunvQ4s9QTofOi4VN +Rr1l5lN8ow2eC21OOcCXBg== +-----END PRIVATE KEY----- diff --git a/video/ingest/src/tests/certs/server.ini b/video/ingest/src/tests/certs/server.ini new file mode 100644 index 00000000..34331807 --- /dev/null +++ b/video/ingest/src/tests/certs/server.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_server + +[v3_server] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/ingest/src/tests/config.rs b/video/ingest/src/tests/config.rs new file mode 100644 index 00000000..1e775112 --- /dev/null +++ b/video/ingest/src/tests/config.rs @@ -0,0 +1,116 @@ +use serial_test::serial; + +use crate::config::AppConfig; + +fn clear_env() { + for (key, _) in std::env::vars() { + if key.starts_with("SCUF_") { + std::env::remove_var(key); + } + } +} + +#[serial] +#[test] +fn test_parse() { + clear_env(); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config, AppConfig::default()); +} + +#[serial] +#[test] +fn test_parse_env() { + clear_env(); + + std::env::set_var("SCUF_LOGGING__LEVEL", "ingest=debug"); + std::env::set_var( + "SCUF_DATABASE__URI", + "postgres://postgres:postgres@localhost:5433/postgres", + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config.logging.level, "ingest=debug"); +} + +#[serial] +#[test] +fn test_parse_file() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "ingest=debug" + +[api] +addresses = [ + "test", + "test2" +] +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "ingest=debug"); + assert_eq!(config.api.addresses, vec!["test", "test2"]); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} + +#[serial] +#[test] +fn test_parse_file_env() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "ingest=debug" + +[rtmp] +bind_address = "[::]:8081" + +[api] +addresses = [ + "test", + "test2" +] +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + std::env::set_var("SCUF_LOGGING__LEVEL", "ingest=info"); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "ingest=info"); + assert_eq!(config.rtmp.bind_address, "[::]:8081".parse().unwrap()); + assert_eq!(config.api.addresses, vec!["test", "test2"]); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} diff --git a/video/ingest/src/tests/global.rs b/video/ingest/src/tests/global.rs new file mode 100644 index 00000000..24a02685 --- /dev/null +++ b/video/ingest/src/tests/global.rs @@ -0,0 +1,33 @@ +use std::{sync::Arc, time::Duration}; + +use common::{ + context::{Context, Handler}, + logging, + prelude::FutureTimeout, +}; + +use crate::{config::AppConfig, global::GlobalState}; + +pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) { + let (ctx, handler) = Context::new(); + + dotenvy::dotenv().ok(); + + logging::init(&config.logging.level, config.logging.json) + .expect("failed to initialize logging"); + + let rmq = common::rmq::ConnectionPool::connect( + std::env::var("RMQ_URL").expect("RMQ_URL not set"), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .expect("failed to connect to rabbitmq") + .expect("failed to connect to rabbitmq"); + + let global = Arc::new(GlobalState::new(config, ctx, rmq)); + + (global, handler) +} diff --git a/video/ingest/src/tests/grpc/health.rs b/video/ingest/src/tests/grpc/health.rs new file mode 100644 index 00000000..a174980e --- /dev/null +++ b/video/ingest/src/tests/grpc/health.rs @@ -0,0 +1,107 @@ +use std::time::Duration; + +use common::grpc::make_channel; +use common::prelude::FutureTimeout; + +use crate::config::{AppConfig, GrpcConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_health_check() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_health_watch() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .watch(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + + let mut stream = resp.into_inner(); + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + + let cancel = handler.cancel(); + + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + ); + + cancel + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/ingest/src/tests/grpc/ingest.rs b/video/ingest/src/tests/grpc/ingest.rs new file mode 100644 index 00000000..900f9673 --- /dev/null +++ b/video/ingest/src/tests/grpc/ingest.rs @@ -0,0 +1,272 @@ +use bytes::Bytes; +use common::grpc::make_channel; +use common::prelude::FutureTimeout; +use std::time::Duration; +use transmuxer::{MediaSegment, MediaType}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, GrpcConfig}, + connection_manager::{GrpcRequest, WatchStreamEvent}, + grpc::run, + pb::scuffle::video::{transcoder_event_request, watch_stream_response, TranscoderEventRequest}, + tests::global::mock_global_state, +}; + +#[tokio::test] +async fn test_grpc_ingest_transcoder_event() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global.clone())); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let stream_id = Uuid::new_v4(); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + global + .connection_manager + .register_stream(stream_id, Uuid::new_v4(), tx) + .await; + + let mut client = crate::pb::scuffle::video::ingest_client::IngestClient::new(channel); + + let request_id = Uuid::new_v4(); + + client + .transcoder_event(TranscoderEventRequest { + stream_id: stream_id.to_string(), + request_id: request_id.to_string(), + event: Some(transcoder_event_request::Event::Started(true)), + }) + .await + .unwrap(); + + let event = rx + .recv() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to receive event") + .expect("failed to receive event"); + + match event { + GrpcRequest::Started { id } => { + assert_eq!(id, request_id); + } + _ => panic!("wrong request"), + } + + client + .transcoder_event(TranscoderEventRequest { + stream_id: stream_id.to_string(), + request_id: request_id.to_string(), + event: Some(transcoder_event_request::Event::ShuttingDown(true)), + }) + .await + .unwrap(); + + let event = rx + .recv() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to receive event") + .expect("failed to receive event"); + + match event { + GrpcRequest::ShuttingDown { id } => { + assert_eq!(id, request_id); + } + _ => panic!("wrong request"), + } + + client + .transcoder_event(TranscoderEventRequest { + stream_id: stream_id.to_string(), + request_id: request_id.to_string(), + event: Some(transcoder_event_request::Event::Error( + transcoder_event_request::Error { + message: "test".to_string(), + fatal: false, + }, + )), + }) + .await + .unwrap(); + + let event = rx + .recv() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to receive event") + .expect("failed to receive event"); + + match event { + GrpcRequest::Error { + id, + message, + fatal: _, + } => { + assert_eq!(id, request_id); + assert_eq!(message, "test"); + } + _ => panic!("wrong request"), + } + + drop(global); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_ingest_watch_stream() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global.clone())); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let stream_id = Uuid::new_v4(); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + global + .connection_manager + .register_stream(stream_id, Uuid::new_v4(), tx) + .await; + + let mut client = crate::pb::scuffle::video::ingest_client::IngestClient::new(channel); + + let request_id = Uuid::new_v4(); + + let mut revc_stream = client + .watch_stream(crate::pb::scuffle::video::WatchStreamRequest { + stream_id: stream_id.to_string(), + request_id: request_id.to_string(), + }) + .await + .unwrap() + .into_inner(); + + let event = rx + .recv() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to receive event") + .expect("failed to receive event"); + + let ch = match event { + GrpcRequest::WatchStream { id, channel } => { + assert_eq!(id, request_id); + channel + } + _ => panic!("wrong request"), + }; + + ch.send(WatchStreamEvent::InitSegment(Bytes::from_static( + b"testing 123", + ))) + .await + .unwrap(); + let resp = revc_stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.data, + Some(watch_stream_response::Data::InitSegment( + b"testing 123".to_vec().into() + )) + ); + + ch.send(WatchStreamEvent::MediaSegment(MediaSegment { + data: Bytes::from_static(b"fragment"), + keyframe: true, + timestamp: 123, + ty: MediaType::Video, + })) + .await + .unwrap(); + let resp = revc_stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.data, + Some(watch_stream_response::Data::MediaSegment( + watch_stream_response::MediaSegment { + data: b"fragment".to_vec().into(), + keyframe: true, + timestamp: 123, + data_type: watch_stream_response::media_segment::DataType::Video as i32, + } + )) + ); + + ch.send(WatchStreamEvent::MediaSegment(MediaSegment { + data: Bytes::from_static(b"fragment2"), + keyframe: false, + timestamp: 456, + ty: MediaType::Audio, + })) + .await + .unwrap(); + let resp = revc_stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.data, + Some(watch_stream_response::Data::MediaSegment( + watch_stream_response::MediaSegment { + data: b"fragment2".to_vec().into(), + keyframe: false, + timestamp: 456, + data_type: watch_stream_response::media_segment::DataType::Audio as i32, + } + )) + ); + + drop(global); + + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/ingest/src/tests/grpc/mod.rs b/video/ingest/src/tests/grpc/mod.rs new file mode 100644 index 00000000..7b85aec1 --- /dev/null +++ b/video/ingest/src/tests/grpc/mod.rs @@ -0,0 +1,3 @@ +mod health; +mod ingest; +mod tls; diff --git a/video/ingest/src/tests/grpc/tls.rs b/video/ingest/src/tests/grpc/tls.rs new file mode 100644 index 00000000..8b9dcb3a --- /dev/null +++ b/video/ingest/src/tests/grpc/tls.rs @@ -0,0 +1,151 @@ +use common::grpc::{make_channel, TlsSettings}; +use common::prelude::FutureTimeout; +use std::path::PathBuf; +use std::time::Duration; +use tonic::transport::{Certificate, Identity}; + +use crate::config::{AppConfig, GrpcConfig, TlsConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_tls_rsa() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs/rsa"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + advertise_address: "".to_string(), + tls: Some(TlsConfig { + cert: dir.join("server.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.crt").to_str().unwrap().to_string(), + key: dir.join("server.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = Certificate::from_pem(std::fs::read_to_string(dir.join("ca.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_tls_ec() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs/ec"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + advertise_address: "".to_string(), + tls: Some(TlsConfig { + cert: dir.join("server.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.crt").to_str().unwrap().to_string(), + key: dir.join("server.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = Certificate::from_pem(std::fs::read_to_string(dir.join("ca.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/ingest/src/tests/ingest.rs b/video/ingest/src/tests/ingest.rs new file mode 100644 index 00000000..fa6f758e --- /dev/null +++ b/video/ingest/src/tests/ingest.rs @@ -0,0 +1,2896 @@ +use std::path::{Path, PathBuf}; +use std::pin::{pin, Pin}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use async_stream::stream; +use async_trait::async_trait; +use futures::StreamExt; +use lapin::options::QueueDeclareOptions; +use prost::Message; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::select; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tonic::{Request, Response, Status}; +use transmuxer::MediaType; +use uuid::Uuid; + +use crate::config::{ApiConfig, AppConfig, RtmpConfig, TlsConfig, TranscoderConfig}; +use crate::connection_manager::{GrpcRequest, WatchStreamEvent}; +use crate::global; +use crate::pb::scuffle::backend::update_live_stream_request::event::Level; +use crate::pb::scuffle::backend::{ + api_server, update_live_stream_request, AuthenticateLiveStreamRequest, + AuthenticateLiveStreamResponse, LiveStreamState, NewLiveStreamRequest, NewLiveStreamResponse, + UpdateLiveStreamRequest, UpdateLiveStreamResponse, +}; +use crate::pb::scuffle::events::{transcoder_message, TranscoderMessage}; +use crate::pb::scuffle::types::stream_variant::{AudioSettings, VideoSettings}; +use crate::pb::scuffle::types::StreamVariant; +use crate::tests::global::mock_global_state; + +#[derive(Debug)] +enum IncomingRequest { + Authenticate( + ( + AuthenticateLiveStreamRequest, + oneshot::Sender>, + ), + ), + Update( + ( + UpdateLiveStreamRequest, + oneshot::Sender>, + ), + ), + New( + ( + NewLiveStreamRequest, + oneshot::Sender>, + ), + ), +} + +struct ApiServer(mpsc::Sender); + +fn new_api_server(port: u16) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(1); + let service = api_server::ApiServer::new(ApiServer(tx)); + + tokio::spawn( + tonic::transport::Server::builder() + .add_service(service) + .serve(format!("0.0.0.0:{}", port).parse().unwrap()), + ); + + rx +} + +type Result = std::result::Result; + +#[async_trait] +impl crate::pb::scuffle::backend::api_server::Api for ApiServer { + async fn authenticate_live_stream( + &self, + request: Request, + ) -> Result> { + let (send, recv) = oneshot::channel(); + self.0 + .send(IncomingRequest::Authenticate((request.into_inner(), send))) + .await + .unwrap(); + Ok(Response::new(recv.await.unwrap()?)) + } + + async fn update_live_stream( + &self, + request: Request, + ) -> Result> { + let (send, recv) = oneshot::channel(); + self.0 + .send(IncomingRequest::Update((request.into_inner(), send))) + .await + .unwrap(); + Ok(Response::new(recv.await.unwrap()?)) + } + + async fn new_live_stream( + &self, + request: Request, + ) -> Result> { + let (send, recv) = oneshot::channel(); + self.0 + .send(IncomingRequest::New((request.into_inner(), send))) + .await + .unwrap(); + Ok(Response::new(recv.await.unwrap()?)) + } +} + +fn stream_with_ffmpeg(rtmp_port: u16, file: &str) -> tokio::process::Child { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + + Command::new("ffmpeg") + .args([ + "-re", + "-i", + dir.join(file).to_str().expect("failed to get path"), + "-c", + "copy", + "-f", + "flv", + &format!("rtmp://127.0.0.1:{}/live/stream-key", rtmp_port), + ]) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("failed to execute ffmpeg") +} + +fn stream_with_ffmpeg_tls(rtmp_port: u16, file: &str, tls_dir: &Path) -> tokio::process::Child { + let video_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + + Command::new("ffmpeg") + .args([ + "-re", + "-i", + video_dir.join(file).to_str().expect("failed to get path"), + "-c", + "copy", + "-tls_verify", + "1", + "-ca_file", + tls_dir.join("ca.crt").to_str().unwrap(), + "-cert_file", + tls_dir.join("client.crt").to_str().unwrap(), + "-key_file", + tls_dir.join("client.key").to_str().unwrap(), + "-f", + "flv", + &format!("rtmps://localhost:{}/live/stream-key", rtmp_port), + ]) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("failed to execute ffmpeg") +} + +fn spawn_ffprobe() -> tokio::process::Child { + Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap() +} + +struct Watcher { + pub rx: tokio::sync::mpsc::Receiver, +} + +impl Watcher { + async fn new(state: &TestState, stream_id: Uuid, request_id: Uuid) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(128); + assert!( + state + .global + .connection_manager + .submit_request( + stream_id, + GrpcRequest::WatchStream { + id: request_id, + channel: tx, + } + ) + .await + ); + Self { rx } + } + + async fn recv(&mut self) -> WatchStreamEvent { + tokio::time::timeout(Duration::from_secs(2), self.rx.recv()) + .await + .expect("failed to receive event") + .expect("failed to receive event") + } +} + +struct TestState { + pub rtmp_port: u16, + pub global: Arc, + pub handler: common::context::Handler, + pub api_rx: mpsc::Receiver, + pub transcoder_stream: Pin>>, + pub ingest_handle: JoinHandle>, +} + +impl TestState { + async fn setup() -> Self { + Self::setup_new(None).await + } + + async fn setup_with_tls(tls_dir: &Path) -> Self { + Self::setup_new(Some(TlsConfig { + cert: tls_dir.join("server.crt").to_str().unwrap().to_string(), + ca_cert: tls_dir.join("ca.crt").to_str().unwrap().to_string(), + key: tls_dir.join("server.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + })) + .await + } + + async fn setup_new(tls: Option) -> Self { + let api_port = portpicker::pick_unused_port().unwrap(); + let rtmp_port = portpicker::pick_unused_port().unwrap(); + + let api_rx = new_api_server(api_port); + + let (global, handler) = mock_global_state(AppConfig { + api: ApiConfig { + addresses: vec![format!("http://localhost:{}", api_port)], + resolve_interval: 1, + tls: None, + }, + rtmp: RtmpConfig { + bind_address: format!("0.0.0.0:{}", rtmp_port).parse().unwrap(), + tls, + }, + transcoder: TranscoderConfig { + events_subject: Uuid::new_v4().to_string(), + }, + ..Default::default() + }) + .await; + + global + .rmq + .aquire() + .await + .unwrap() + .queue_declare( + &global.config.transcoder.events_subject.clone(), + QueueDeclareOptions { + auto_delete: true, + durable: false, + ..Default::default() + }, + Default::default(), + ) + .await + .unwrap(); + + let ingest_handle = tokio::spawn(crate::ingest::run(global.clone())); + + let stream = { + let global = global.clone(); + stream! { + let mut stream = pin!(global.rmq.basic_consume(global.config.transcoder.events_subject.clone(), "", Default::default(), Default::default())); + loop { + select! { + message = stream.next() => { + let message = message.unwrap().unwrap(); + yield TranscoderMessage::decode(message.data.as_slice()).unwrap(); + } + _ = global.ctx.done() => { + break; + } + } + } + } + }; + + Self { + rtmp_port, + global, + handler, + api_rx, + transcoder_stream: Box::pin(stream), + ingest_handle, + } + } + + async fn transcoder_message(&mut self) -> TranscoderMessage { + tokio::time::timeout(Duration::from_secs(2), self.transcoder_stream.next()) + .await + .expect("failed to receive event") + .expect("failed to receive event") + } + + async fn api_recv(&mut self) -> IncomingRequest { + tokio::time::timeout(Duration::from_secs(2), self.api_rx.recv()) + .await + .expect("failed to receive event") + .expect("failed to receive event") + } + + fn finish(self) -> impl futures::Future { + let handler = self.handler; + let ingest_handle = self.ingest_handle; + async move { + handler.cancel().await; + assert!(ingest_handle.is_finished()); + } + } + + async fn api_assert_authenticate(&mut self, response: Result) { + match self.api_recv().await { + IncomingRequest::Authenticate((request, send)) => { + assert_eq!(request.stream_key, "stream-key"); + assert_eq!(request.app_name, "live"); + assert!(!request.connection_id.is_empty()); + assert!(!request.ingest_address.is_empty()); + send.send(response).unwrap(); + } + _ => panic!("unexpected event"), + } + } + + async fn api_assert_authenticate_ok(&mut self, record: bool, transcode: bool) -> Uuid { + let stream_id = Uuid::new_v4(); + self.api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { + stream_id: stream_id.to_string(), + record, + transcode, + try_resume: false, + variants: vec![], + })) + .await; + stream_id + } +} + +#[tokio::test] +async fn test_ingest_stream() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, false).await; + + let variants; + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 2); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 468, + height: 864, + framerate: 30, + bitrate: 1276158, + codec: "avc1.64001f".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + variants = v.variants.clone(); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + assert_eq!(ms.ty, MediaType::Video); + assert_eq!(ms.timestamp, 0); + } + _ => panic!("unexpected event"), + } + + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::ShuttingDown { id: request_id }) + .await; + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // It should now create a new transcoder to handle the stream + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut new_watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut previous_audio_ts = 0; + let mut previous_video_ts = 0; + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(!ms.keyframe); + match ms.ty { + MediaType::Audio => { + assert!(ms.timestamp >= previous_audio_ts); + previous_audio_ts = ms.timestamp; + } + MediaType::Video => { + assert!(ms.timestamp >= previous_video_ts); + previous_video_ts = ms.timestamp; + } + } + } + WatchStreamEvent::ShuttingDown(false) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + match new_watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match new_watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + assert_eq!(ms.timestamp, 1000); + assert_eq!(ms.ty, MediaType::Video); + previous_video_ts = 1000; + } + _ => panic!("unexpected event"), + } + + while let Ok(msg) = new_watcher.rx.try_recv() { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + match ms.ty { + MediaType::Audio => { + assert!(ms.timestamp >= previous_audio_ts); + previous_audio_ts = ms.timestamp; + } + MediaType::Video => { + assert!(ms.timestamp >= previous_video_ts); + previous_video_ts = ms.timestamp; + } + } + } + _ => panic!("unexpected event"), + } + } + + // Assert that no messages with keyframes made it to the old channel + + ffmpeg.kill().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::StoppedResumable as i32); + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + tracing::info!("waiting for transcoder to exit"); + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_disconnect() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, true).await; + + let variants; + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 3); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 468, + height: 864, + framerate: 30, + bitrate: 1276158, + codec: "avc1.64001f".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + assert_eq!(v.variants[2].name, "360p"); + assert_eq!(v.variants[2].video_settings, Some(VideoSettings { + width: 360, + height: 665, + framerate: 30, + bitrate: 1024000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[2].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[2].metadata, "{}"); + assert!(!v.variants[2].id.is_empty()); + + variants = v.variants.clone(); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + // Force disconnect the transcoder + drop(watcher); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Transcoder Disconnected"); + assert_eq!(event.level, Level::Warning as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // It should now create a new transcoder to handle the stream + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + ffmpeg.kill().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::StoppedResumable as i32); + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_shutdown() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, false).await; + + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 2); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 468, + height: 864, + framerate: 30, + bitrate: 1276158, + codec: "avc1.64001f".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 44100, + channels: 2, + bitrate: 69568, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // state.global + // .nats + // .publish( + // format!( + // "{}.{}", + // state.global.config.nats.connection_subject_prefix, stream_id + // ), + // IngestMessage { + // timestamp: 0, + // id: Uuid::new_v4().to_string(), + // data: Some(ingest_message::Data::DropStream(IngestMessageDropStream { + // id: stream_id.to_string(), + // })), + // } + // .encode_to_vec() + // .into(), + // ) + // .await + // .unwrap(); + + assert!(ffmpeg.wait().await.is_ok()); + + // drop(transcoder_stream); + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_full() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, true).await; + + let variants; + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 5); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 3840, + height: 2160, + framerate: 60, + bitrate: 1740285, + codec: "avc1.640034".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + assert_eq!(v.variants[2].name, "720p"); + assert_eq!(v.variants[2].video_settings, Some(VideoSettings { + width: 1280, + height: 720, + framerate: 60, + bitrate: 4096000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[2].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[2].metadata, "{}"); + assert!(!v.variants[2].id.is_empty()); + + assert_eq!(v.variants[3].name, "480p"); + assert_eq!(v.variants[3].video_settings, Some(VideoSettings { + width: 853, + height: 480, + framerate: 30, + bitrate: 2048000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[3].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[3].metadata, "{}"); + assert!(!v.variants[3].id.is_empty()); + + assert_eq!(v.variants[4].name, "360p"); + assert_eq!(v.variants[4].video_settings, Some(VideoSettings { + width: 640, + height: 360, + framerate: 30, + bitrate: 1024000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[4].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[4].metadata, "{}"); + assert!(!v.variants[4].id.is_empty()); + + variants = v.variants.clone(); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Ready as i32); // Stream is ready + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_reject() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); + + let stream_id = Uuid::new_v4(); + state + .api_assert_authenticate(Err(Status::permission_denied("invalid stream key"))) + .await; + + assert!( + tokio::time::timeout(Duration::from_secs(1), state.transcoder_stream.next()) + .await + .is_err() + ); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: Uuid::new_v4() }) + .await + ); + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_error() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, true).await; + + let variants; + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 5); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 3840, + height: 2160, + framerate: 60, + bitrate: 1740285, + codec: "avc1.640034".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + assert_eq!(v.variants[2].name, "720p"); + assert_eq!(v.variants[2].video_settings, Some(VideoSettings { + width: 1280, + height: 720, + framerate: 60, + bitrate: 4096000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[2].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[2].metadata, "{}"); + assert!(!v.variants[2].id.is_empty()); + + assert_eq!(v.variants[3].name, "480p"); + assert_eq!(v.variants[3].video_settings, Some(VideoSettings { + width: 853, + height: 480, + framerate: 30, + bitrate: 2048000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[3].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[3].metadata, "{}"); + assert!(!v.variants[3].id.is_empty()); + + assert_eq!(v.variants[4].name, "360p"); + assert_eq!(v.variants[4].video_settings, Some(VideoSettings { + width: 640, + height: 360, + framerate: 30, + bitrate: 1024000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[4].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[4].metadata, "{}"); + assert!(!v.variants[4].id.is_empty()); + + variants = v.variants.clone(); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request( + stream_id, + GrpcRequest::Error { + id: request_id, + message: "test".to_string(), + fatal: false, + } + ) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 2); + + let u = &update.updates[0]; + assert!(u.timestamp > 0); + + match &u.update { + Some(update_live_stream_request::update::Update::Event(ev)) => { + assert_eq!(ev.title, "Transcoder Error"); + assert_eq!(ev.message, "test"); + assert_eq!(ev.level, Level::Error as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + let u = &update.updates[1]; + assert!(u.timestamp > 0); + + match &u.update { + Some(update_live_stream_request::update::Update::State(s)) => { + assert_eq!(*s, LiveStreamState::Failed as i32); + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => {} + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + assert!( + tokio::time::timeout(Duration::from_secs(1), state.api_rx.recv()) + .await + .is_err() + ); + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_try_resume_success() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); + + let stream_id = Uuid::new_v4(); + let variants = vec![ + StreamVariant { + id: Uuid::new_v4().to_string(), + metadata: "{}".to_string(), + name: "source".to_string(), + audio_settings: Some(AudioSettings { + bitrate: 140304, + channels: 2, + sample_rate: 48000, + codec: "opus".to_string(), + }), + video_settings: Some(VideoSettings { + width: 3840, + height: 2160, + framerate: 60, + bitrate: 1740285, + codec: "avc1.640034".to_string(), + }), + }, + StreamVariant { + id: Uuid::new_v4().to_string(), + metadata: "{}".to_string(), + name: "audio".to_string(), + video_settings: None, + audio_settings: Some(AudioSettings { + bitrate: 140304, + channels: 2, + sample_rate: 48000, + codec: "opus".to_string(), + }), + }, + ]; + state + .api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { + stream_id: stream_id.to_string(), + record: false, + transcode: false, + try_resume: true, + variants: variants.clone(), + })) + .await; + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Ready as i32); // Stream is ready + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_try_resume_failed() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_large.mp4"); + + let mut stream_id = Uuid::new_v4(); + state + .api_assert_authenticate(Ok(AuthenticateLiveStreamResponse { + stream_id: stream_id.to_string(), + record: false, + transcode: false, + try_resume: true, + variants: vec![ + StreamVariant { + id: Uuid::new_v4().to_string(), + metadata: "{}".to_string(), + name: "source".to_string(), + audio_settings: Some(AudioSettings { + bitrate: 140304, + channels: 2, + sample_rate: 48000, + codec: "opus".to_string(), + }), + video_settings: Some(VideoSettings { + width: 1920, + height: 1080, + framerate: 60, + bitrate: 1740285, + codec: "avc1.640034".to_string(), + }), + }, + StreamVariant { + id: Uuid::new_v4().to_string(), + metadata: "{}".to_string(), + name: "audio".to_string(), + video_settings: None, + audio_settings: Some(AudioSettings { + bitrate: 140304, + channels: 2, + sample_rate: 48000, + codec: "opus".to_string(), + }), + }, + ], + })) + .await; + + let variants; + match state.api_recv().await { + IncomingRequest::New((new, response)) => { + assert_eq!(new.old_stream_id, stream_id.to_string()); + assert_eq!(new.variants.len(), 2); + + assert_eq!(new.variants[0].name, "source"); + assert_eq!( + new.variants[0].audio_settings.as_ref().unwrap().bitrate, + 140304 + ); + assert_eq!( + new.variants[0].video_settings.as_ref().unwrap().bitrate, + 1740285 + ); + assert_eq!( + new.variants[0].video_settings.as_ref().unwrap().framerate, + 60 + ); + assert_eq!(new.variants[0].video_settings.as_ref().unwrap().width, 3840); + assert_eq!( + new.variants[0].video_settings.as_ref().unwrap().height, + 2160 + ); + assert_eq!( + new.variants[0].video_settings.as_ref().unwrap().codec, + "avc1.640034" + ); + assert_eq!( + new.variants[0].audio_settings.as_ref().unwrap().codec, + "opus" + ); + assert_eq!(new.variants[0].audio_settings.as_ref().unwrap().channels, 2); + assert_eq!( + new.variants[0].audio_settings.as_ref().unwrap().sample_rate, + 48000 + ); + assert_eq!(new.variants[0].metadata, "{}"); + + assert_eq!(new.variants[1].name, "audio"); + assert_eq!( + new.variants[1].audio_settings.as_ref().unwrap().bitrate, + 140304 + ); + assert_eq!(new.variants[1].video_settings, None); + assert_eq!( + new.variants[1].audio_settings.as_ref().unwrap().codec, + "opus" + ); + assert_eq!(new.variants[1].audio_settings.as_ref().unwrap().channels, 2); + assert_eq!( + new.variants[1].audio_settings.as_ref().unwrap().sample_rate, + 48000 + ); + assert_eq!(new.variants[1].metadata, "{}"); + + variants = new.variants; + + stream_id = Uuid::new_v4(); + + response + .send(Ok(NewLiveStreamResponse { + stream_id: stream_id.to_string(), + })) + .unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Ready as i32); // Stream is ready + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +async fn test_ingest_stream_transcoder_full_tls(tls_dir: PathBuf) { + let mut state = TestState::setup_with_tls(&tls_dir).await; + let mut ffmpeg = stream_with_ffmpeg_tls(state.rtmp_port, "avc_aac_large.mp4", &tls_dir); + + let stream_id = Uuid::new_v4(); + match state.api_recv().await { + IncomingRequest::Authenticate((request, send)) => { + assert_eq!(request.stream_key, "stream-key"); + assert_eq!(request.app_name, "live"); + assert!(!request.connection_id.is_empty()); + assert!(!request.ingest_address.is_empty()); + send.send(Ok(AuthenticateLiveStreamResponse { + stream_id: stream_id.to_string(), + record: false, + transcode: true, + try_resume: false, + variants: vec![], + })) + .unwrap(); + } + _ => panic!("unexpected event"), + } + + let variants; + match state.api_recv().await { + IncomingRequest::Update((request, send)) => { + assert_eq!(request.stream_id, stream_id.to_string()); + match &request.updates[0].update { + Some(crate::pb::scuffle::backend::update_live_stream_request::update::Update::Variants(v)) => { + assert_eq!(v.variants.len(), 5); // We are not transcoding so this is source and audio only + assert_eq!(v.variants[0].name, "source"); + assert_eq!(v.variants[0].video_settings, Some(VideoSettings { + width: 3840, + height: 2160, + framerate: 60, + bitrate: 1740285, + codec: "avc1.640034".to_string(), + })); + assert_eq!(v.variants[0].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[0].metadata, "{}"); + assert!(!v.variants[0].id.is_empty()); + + assert_eq!(v.variants[1].name, "audio"); + assert_eq!(v.variants[1].video_settings, None); + assert_eq!(v.variants[1].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[1].metadata, "{}"); + assert!(!v.variants[1].id.is_empty()); + + assert_eq!(v.variants[2].name, "720p"); + assert_eq!(v.variants[2].video_settings, Some(VideoSettings { + width: 1280, + height: 720, + framerate: 60, + bitrate: 4096000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[2].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[2].metadata, "{}"); + assert!(!v.variants[2].id.is_empty()); + + assert_eq!(v.variants[3].name, "480p"); + assert_eq!(v.variants[3].video_settings, Some(VideoSettings { + width: 853, + height: 480, + framerate: 30, + bitrate: 2048000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[3].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[3].metadata, "{}"); + assert!(!v.variants[3].id.is_empty()); + + assert_eq!(v.variants[4].name, "360p"); + assert_eq!(v.variants[4].video_settings, Some(VideoSettings { + width: 640, + height: 360, + framerate: 30, + bitrate: 1024000, + codec: "avc1.640033".to_string(), + })); + assert_eq!(v.variants[4].audio_settings, Some(AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 140304, + codec: "opus".to_string(), + })); + assert_eq!(v.variants[4].metadata, "{}"); + assert!(!v.variants[4].id.is_empty()); + + variants = v.variants.clone(); + + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + }, + _ => panic!("unexpected update"), + } + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::Event(event)) => { + assert_eq!(event.title, "Requested Transcoder"); + assert_eq!( + event.message, + "Requested a transcoder to be assigned to this stream" + ); + assert_eq!(event.level, Level::Info as i32) + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + assert_eq!(data.variants, variants); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => assert!(!data.is_empty()), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + assert!(ms.keyframe); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Ready as i32); // Stream is ready + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + assert!(!ms.data.is_empty()); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_full_tls_rsa() { + test_ingest_stream_transcoder_full_tls( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs/rsa"), + ) + .await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_full_tls_ec() { + test_ingest_stream_transcoder_full_tls( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs/ec"), + ) + .await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_probe() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, false).await; + + match state.api_recv().await { + IncomingRequest::Update((_, send)) => { + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + _ => panic!("unexpected event"), + } + + match watcher.recv().await { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + assert!(ffmpeg.try_wait().is_ok()); + + let output = ffprobe.wait_with_output().await.unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + { + let video_stream = &json["streams"][0]; + assert_eq!(video_stream["codec_type"], "video"); + assert_eq!(video_stream["codec_name"], "h264"); + assert_eq!(video_stream["width"], 480); + assert_eq!(video_stream["height"], 852); + assert_eq!(video_stream["r_frame_rate"], "30/1"); + assert_eq!(video_stream["avg_frame_rate"], "30/1"); + assert_eq!(video_stream["time_base"], "1/30000"); + assert_eq!(video_stream["codec_tag"], "0x31637661"); + assert_eq!(video_stream["codec_tag_string"], "avc1"); + assert_eq!(video_stream["profile"], "High"); + assert_eq!(video_stream["level"], 31); + assert_eq!(video_stream["refs"], 1); + assert_eq!(video_stream["is_avc"], "true"); + + let audio_stream = &json["streams"][1]; + assert_eq!(audio_stream["codec_type"], "audio"); + assert_eq!(audio_stream["codec_name"], "aac"); + assert_eq!(audio_stream["sample_rate"], "44100"); + assert_eq!(audio_stream["channels"], 1); + assert_eq!(audio_stream["channel_layout"], "mono"); + assert_eq!(audio_stream["r_frame_rate"], "0/0"); + assert_eq!(audio_stream["avg_frame_rate"], "0/0"); + assert_eq!(audio_stream["time_base"], "1/44100"); + assert_eq!(audio_stream["codec_tag"], "0x6134706d"); + assert_eq!(audio_stream["codec_tag_string"], "mp4a"); + assert_eq!(audio_stream["profile"], "LC"); + } + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_probe_reconnect() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, false).await; + + match state.api_recv().await { + IncomingRequest::Update((_, send)) => { + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut i = 0; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + _ => panic!("unexpected event"), + } + i += 1; + + if i > 10 { + break; + } + } + + let output = ffprobe.wait_with_output().await.unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + { + let video_stream = &json["streams"][0]; + assert_eq!(video_stream["codec_type"], "video"); + assert_eq!(video_stream["codec_name"], "h264"); + assert_eq!(video_stream["width"], 480); + assert_eq!(video_stream["height"], 852); + assert_eq!(video_stream["r_frame_rate"], "30/1"); + assert_eq!(video_stream["avg_frame_rate"], "30/1"); + assert_eq!(video_stream["time_base"], "1/30000"); + assert_eq!(video_stream["codec_tag"], "0x31637661"); + assert_eq!(video_stream["codec_tag_string"], "avc1"); + assert_eq!(video_stream["profile"], "High"); + assert_eq!(video_stream["level"], 31); + assert_eq!(video_stream["refs"], 1); + assert_eq!(video_stream["is_avc"], "true"); + + let audio_stream = &json["streams"][1]; + assert_eq!(audio_stream["codec_type"], "audio"); + assert_eq!(audio_stream["codec_name"], "aac"); + assert_eq!(audio_stream["sample_rate"], "44100"); + assert_eq!(audio_stream["channels"], 1); + assert_eq!(audio_stream["channel_layout"], "mono"); + assert_eq!(audio_stream["r_frame_rate"], "0/0"); + assert_eq!(audio_stream["avg_frame_rate"], "0/0"); + assert_eq!(audio_stream["time_base"], "1/44100"); + assert_eq!(audio_stream["codec_tag"], "0x6134706d"); + assert_eq!(audio_stream["codec_tag_string"], "mp4a"); + assert_eq!(audio_stream["profile"], "LC"); + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::ShuttingDown { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut new_watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(_) => {} + WatchStreamEvent::ShuttingDown(false) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event: {:?}", msg), + } + } + + assert!(got_shutting_down); + + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); + + match new_watcher.recv().await { + WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = new_watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + let output = ffprobe.wait_with_output().await.unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + { + let video_stream = &json["streams"][0]; + assert_eq!(video_stream["codec_type"], "video"); + assert_eq!(video_stream["codec_name"], "h264"); + assert_eq!(video_stream["width"], 480); + assert_eq!(video_stream["height"], 852); + assert_eq!(video_stream["r_frame_rate"], "30/1"); + assert_eq!(video_stream["avg_frame_rate"], "30/1"); + assert_eq!(video_stream["time_base"], "1/30000"); + assert_eq!(video_stream["codec_tag"], "0x31637661"); + assert_eq!(video_stream["codec_tag_string"], "avc1"); + assert_eq!(video_stream["profile"], "High"); + assert_eq!(video_stream["level"], 31); + assert_eq!(video_stream["refs"], 1); + assert_eq!(video_stream["is_avc"], "true"); + + let audio_stream = &json["streams"][1]; + assert_eq!(audio_stream["codec_type"], "audio"); + assert_eq!(audio_stream["codec_name"], "aac"); + assert_eq!(audio_stream["sample_rate"], "44100"); + assert_eq!(audio_stream["channels"], 1); + assert_eq!(audio_stream["channel_layout"], "mono"); + assert_eq!(audio_stream["r_frame_rate"], "0/0"); + assert_eq!(audio_stream["avg_frame_rate"], "0/0"); + assert_eq!(audio_stream["time_base"], "1/44100"); + assert_eq!(audio_stream["codec_tag"], "0x6134706d"); + assert_eq!(audio_stream["codec_tag_string"], "mp4a"); + assert_eq!(audio_stream["profile"], "LC"); + } + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} + +#[tokio::test] +async fn test_ingest_stream_transcoder_probe_reconnect_unexpected() { + let mut state = TestState::setup().await; + let mut ffmpeg = stream_with_ffmpeg(state.rtmp_port, "avc_aac_keyframes.mp4"); + + let stream_id = state.api_assert_authenticate_ok(false, false).await; + + match state.api_recv().await { + IncomingRequest::Update((_, send)) => { + send.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut i = 0; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + _ => panic!("unexpected event"), + } + i += 1; + + if i > 10 { + break; + } + } + + let output = ffprobe.wait_with_output().await.unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + { + let video_stream = &json["streams"][0]; + assert_eq!(video_stream["codec_type"], "video"); + assert_eq!(video_stream["codec_name"], "h264"); + assert_eq!(video_stream["width"], 480); + assert_eq!(video_stream["height"], 852); + assert_eq!(video_stream["r_frame_rate"], "30/1"); + assert_eq!(video_stream["avg_frame_rate"], "30/1"); + assert_eq!(video_stream["time_base"], "1/30000"); + assert_eq!(video_stream["codec_tag"], "0x31637661"); + assert_eq!(video_stream["codec_tag_string"], "avc1"); + assert_eq!(video_stream["profile"], "High"); + assert_eq!(video_stream["level"], 31); + assert_eq!(video_stream["refs"], 1); + assert_eq!(video_stream["is_avc"], "true"); + + let audio_stream = &json["streams"][1]; + assert_eq!(audio_stream["codec_type"], "audio"); + assert_eq!(audio_stream["codec_name"], "aac"); + assert_eq!(audio_stream["sample_rate"], "44100"); + assert_eq!(audio_stream["channels"], 1); + assert_eq!(audio_stream["channel_layout"], "mono"); + assert_eq!(audio_stream["r_frame_rate"], "0/0"); + assert_eq!(audio_stream["avg_frame_rate"], "0/0"); + assert_eq!(audio_stream["time_base"], "1/44100"); + assert_eq!(audio_stream["codec_tag"], "0x6134706d"); + assert_eq!(audio_stream["codec_tag_string"], "mp4a"); + assert_eq!(audio_stream["profile"], "LC"); + } + + // Now drop the stream + drop(watcher); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + let msg = state.transcoder_message().await; + assert!(!msg.id.is_empty()); + assert!(msg.timestamp > 0); + let data = match msg.data { + Some(transcoder_message::Data::NewStream(data)) => data, + _ => panic!("unexpected message"), + }; + + assert!(!data.request_id.is_empty()); + assert_eq!(data.stream_id, stream_id.to_string()); + + // We should now be able to join the stream + let stream_id = data.stream_id.parse().unwrap(); + let request_id = data.request_id.parse().unwrap(); + let mut watcher = Watcher::new(&state, stream_id, request_id).await; + + let mut ffprobe = spawn_ffprobe(); + let writer = ffprobe.stdin.as_mut().unwrap(); + + match watcher.recv().await { + WatchStreamEvent::InitSegment(data) => writer.write_all(&data).await.unwrap(), + _ => panic!("unexpected event"), + } + + assert!( + state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + match state.api_recv().await { + IncomingRequest::Update((_, response)) => { + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + // Finish the stream + let mut got_shutting_down = false; + while let Some(msg) = watcher.rx.recv().await { + match msg { + WatchStreamEvent::MediaSegment(ms) => { + writer.write_all(&ms.data).await.unwrap(); + } + WatchStreamEvent::ShuttingDown(true) => { + got_shutting_down = true; + break; + } + _ => panic!("unexpected event"), + } + } + + assert!(got_shutting_down); + + let output = ffprobe.wait_with_output().await.unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + { + let video_stream = &json["streams"][0]; + assert_eq!(video_stream["codec_type"], "video"); + assert_eq!(video_stream["codec_name"], "h264"); + assert_eq!(video_stream["width"], 480); + assert_eq!(video_stream["height"], 852); + assert_eq!(video_stream["r_frame_rate"], "30/1"); + assert_eq!(video_stream["avg_frame_rate"], "30/1"); + assert_eq!(video_stream["time_base"], "1/30000"); + assert_eq!(video_stream["codec_tag"], "0x31637661"); + assert_eq!(video_stream["codec_tag_string"], "avc1"); + assert_eq!(video_stream["profile"], "High"); + assert_eq!(video_stream["level"], 31); + assert_eq!(video_stream["refs"], 1); + assert_eq!(video_stream["is_avc"], "true"); + + let audio_stream = &json["streams"][1]; + assert_eq!(audio_stream["codec_type"], "audio"); + assert_eq!(audio_stream["codec_name"], "aac"); + assert_eq!(audio_stream["sample_rate"], "44100"); + assert_eq!(audio_stream["channels"], 1); + assert_eq!(audio_stream["channel_layout"], "mono"); + assert_eq!(audio_stream["r_frame_rate"], "0/0"); + assert_eq!(audio_stream["avg_frame_rate"], "0/0"); + assert_eq!(audio_stream["time_base"], "1/44100"); + assert_eq!(audio_stream["codec_tag"], "0x6134706d"); + assert_eq!(audio_stream["codec_tag_string"], "mp4a"); + assert_eq!(audio_stream["profile"], "LC"); + } + + assert!(ffmpeg.try_wait().is_ok()); + + // Assert that the stream is removed + assert!( + !state + .global + .connection_manager + .submit_request(stream_id, GrpcRequest::Started { id: request_id }) + .await + ); + + // Assert that the stream is removed + match state.api_recv().await { + IncomingRequest::Update((update, response)) => { + assert_eq!(update.stream_id, stream_id.to_string()); + assert_eq!(update.updates.len(), 1); + + let update = &update.updates[0]; + assert!(update.timestamp > 0); + + match &update.update { + Some(update_live_stream_request::update::Update::State(state)) => { + assert_eq!(*state, LiveStreamState::Stopped as i32); // graceful stop + } + u => { + panic!("unexpected update: {:?}", u); + } + } + + response.send(Ok(UpdateLiveStreamResponse {})).unwrap(); + } + _ => panic!("unexpected event"), + } + + state.finish().await; +} diff --git a/video/ingest/src/tests/mod.rs b/video/ingest/src/tests/mod.rs new file mode 100644 index 00000000..36c36dae --- /dev/null +++ b/video/ingest/src/tests/mod.rs @@ -0,0 +1,4 @@ +mod config; +mod global; +mod grpc; +mod ingest; diff --git a/video/protocol/rtmp/Cargo.toml b/video/protocol/rtmp/Cargo.toml new file mode 100644 index 00000000..54f3ebee --- /dev/null +++ b/video/protocol/rtmp/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rtmp" +version = "0.0.1" +edition = "2021" + +[dependencies] +byteorder = "1" +bytes = "1" +rand = "0" +hmac = "0" +sha2 = "0" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0", default-features = false, features = ["clock"] } +num-traits = "0" +num-derive = "0" +tokio = "1" +futures = "0" +async-trait = "0" +tracing = "0" + +bytesio = {path = "../../bytesio" } +flv = { path = "../../container/flv" } +h264 = { path = "../../codec/h264" } +amf0 = { path = "../../utils/amf0" } + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +serde_json = "1" +common = { path = "../../../common" } \ No newline at end of file diff --git a/video/protocol/rtmp/src/channels/mod.rs b/video/protocol/rtmp/src/channels/mod.rs new file mode 100644 index 00000000..db7a4498 --- /dev/null +++ b/video/protocol/rtmp/src/channels/mod.rs @@ -0,0 +1,24 @@ +use bytes::Bytes; +use tokio::sync::{mpsc, oneshot}; + +pub type UniqueID = uuid::Uuid; + +#[derive(Clone, Debug)] +pub enum ChannelData { + Video { timestamp: u32, data: Bytes }, + Audio { timestamp: u32, data: Bytes }, + MetaData { timestamp: u32, data: Bytes }, +} + +#[derive(Debug)] +pub struct PublishRequest { + pub app_name: String, + pub stream_name: String, + pub response: oneshot::Sender, +} + +pub type PublishProducer = mpsc::Sender; +pub type PublishConsumer = mpsc::Receiver; + +pub type DataProducer = mpsc::Sender; +pub type DataConsumer = mpsc::Receiver; diff --git a/video/protocol/rtmp/src/chunk/decoder.rs b/video/protocol/rtmp/src/chunk/decoder.rs new file mode 100644 index 00000000..36c874b1 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/decoder.rs @@ -0,0 +1,492 @@ +use super::{ + define::{ + Chunk, ChunkBasicHeader, ChunkMessageHeader, ChunkType, INIT_CHUNK_SIZE, MAX_CHUNK_SIZE, + }, + errors::ChunkDecodeError, +}; +use crate::messages::MessageTypeID; +use byteorder::{BigEndian, LittleEndian, ReadBytesExt}; +use bytes::BytesMut; +use bytesio::bytes_reader::BytesReader; +use num_traits::FromPrimitive; +use std::{ + cmp::min, + collections::HashMap, + io::SeekFrom, + io::{Cursor, Seek}, +}; + +// These constants are used to limit the amount of memory we use for partial chunks +// on normal operations we should never hit these limits +// This is for when someone is trying to send us a malicious chunk streams +const MAX_PARTIAL_CHUNK_SIZE: usize = 10 * 1024 * 1024; // 10MB (should be more than enough) +const MAX_PREVIOUS_CHUNK_HEADERS: usize = 100; // 100 chunks +const MAX_PARTIAL_CHUNK_COUNT: usize = 4; // 4 chunks + +pub struct ChunkDecoder { + /// Our reader is a bytes reader that is used to read the bytes. + /// This is a wrapper around a bytes mut. + reader: BytesReader, + + /// According to the spec chunk streams are identified by the chunk stream ID. + /// In this case that is our key. + /// We then have a chunk header (since some chunks refer to the previous chunk header) + previous_chunk_headers: HashMap, + + /// Technically according to the spec, we can have multiple message_streams in a single chunk_stream + /// Because of this we actually have to have a map of chunk streams to message streams to bytes + /// The first u32 is the chunk stream id, the second is the message stream id + partial_chunks: HashMap<(u32, u32), BytesMut>, + + /// This is the max chunk size that the client has specified. + /// By default this is 128 bytes. + max_chunk_size: usize, +} + +impl Default for ChunkDecoder { + fn default() -> Self { + Self { + reader: BytesReader::new(BytesMut::new()), + previous_chunk_headers: HashMap::new(), + partial_chunks: HashMap::new(), + max_chunk_size: INIT_CHUNK_SIZE, + } + } +} + +impl ChunkDecoder { + /// This function is used to extend the data that we have.f + pub fn extend_data(&mut self, data: &[u8]) { + self.reader.extend_from_slice(data); + } + + /// Sometimes a client will request a chunk size change. + pub fn update_max_chunk_size(&mut self, chunk_size: usize) -> bool { + // We need to make sure that the chunk size is within the allowed range. + // Returning false here will close the connection. + if !(INIT_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&chunk_size) { + false + } else { + self.max_chunk_size = chunk_size; + true + } + } + + /// This function is used to read a chunk from the buffer. + /// - will return Ok(None) if the buffer is empty. + /// - will return Ok(Some(Chunk)) if we have a full chunk. + /// - Err(UnpackError) if we have an error. This will close the connection. + pub fn read_chunk(&mut self) -> Result, ChunkDecodeError> { + // We do this in a loop because we may have multiple chunks in the buffer, + // And those chunks may be partial chunks thus we need to keep reading until we have + // a full chunk or we run out of data. + loop { + // The cursor is an advanced cursor that is a reference to the buffer. + // This means the cursor does not advance the reader's position. + // Thus allowing us to backtrack if we need to read more data. + let mut cursor = self.reader.advance_bytes_cursor(self.reader.len())?; + + let header = match self.read_header(&mut cursor) { + Ok(header) => header, + Err(None) => { + // Returning none here means that the buffer is empty and we need to wait for + // more data. + return Ok(None); + } + Err(Some(err)) => { + // This is an error that we can't recover from, so we return it. + // The connection will be closed. + return Err(err); + } + }; + + let message_header = match self.read_message_header(&header, &mut cursor) { + Ok(message_header) => message_header, + Err(None) => { + // Returning none here means that the buffer is empty and we need to wait for + // more data. + return Ok(None); + } + Err(Some(err)) => { + // This is an error that we can't recover from, so we return it. + // The connection will be closed. + return Err(err); + } + }; + + let (payload_range_start, payload_range_end) = + match self.get_payload_range(&header, &message_header, &mut cursor) { + Ok(data) => data, + Err(None) => { + // Returning none here means that the buffer is empty and we need to wait for + // more data. + return Ok(None); + } + Err(Some(err)) => { + // This is an error that we can't recover from, so we return it. + // The connection will be closed. + return Err(err); + } + }; + + // Since we were reading from an advanced cursor, our reads did not actually advance the + // reader's position. We need to manually advance the reader's position to the cursor's + // position. + let Ok(data) = self.reader.read_bytes(cursor.position() as usize) else { + // This means that the payload range was larger than the buffer. + // This happens when we dont have enough data to read the payload. + // We need to wait for more data. + return Ok(None); + }; + + // We freeze the chunk data and slice it to get the payload. + // Data before the slice is the header data, and data after the slice is the next chunk + // We don't need to keep the header data, because we already decoded it into struct form. + // The payload_range_end should be the same as the cursor's position. + let payload = data.freeze().slice(payload_range_start..payload_range_end); + + // We need to check here if the chunk header is already stored in our map. + // This isnt a spec check but it is a check to make sure that we dont have too many + // previous chunk headers stored in memory. + let count = if self + .previous_chunk_headers + .contains_key(&header.chunk_stream_id) + { + self.previous_chunk_headers.len() + } else { + self.previous_chunk_headers.len() + 1 + }; + + // If this is hit, then we have too many previous chunk headers stored in memory. + // And the client is probably trying to DoS us. + // We return an error and the connection will be closed. + if count > MAX_PREVIOUS_CHUNK_HEADERS { + return Err(ChunkDecodeError::TooManyPreviousChunkHeaders); + } + + // We insert the chunk header into our map. + self.previous_chunk_headers + .insert(header.chunk_stream_id, message_header.clone()); + + // It is possible in theory to get a chunk message that requires us to change the max chunk size. + // However the size of that message is smaller than the default max chunk size. + // Therefore we can ignore this case. + // Since if we get such a message we will read it and the payload.len() will be equal to the message length. + // and thus we will return the chunk. + + // Check if the payload is the same as the message length. + // If this is true we have a full chunk and we can return it. + if payload.len() == message_header.msg_length as usize { + return Ok(Some(Chunk { + basic_header: header, + message_header, + payload, + })); + } else { + // Otherwise we generate a key using the chunk stream id and the message stream id. + // We then get the partial chunk from the map using the key. + let key = (header.chunk_stream_id, message_header.msg_stream_id); + let partial_chunk = match self.partial_chunks.get_mut(&key) { + Some(partial_chunk) => partial_chunk, + None => { + // If it does not exists we create a new one. + // If we have too many partial chunks we return an error. + // Since the client is probably trying to DoS us. + // The connection will be closed. + if self.partial_chunks.len() >= MAX_PARTIAL_CHUNK_COUNT { + return Err(ChunkDecodeError::TooManyPartialChunks); + } + + // Insert a new empty BytesMut into the map. + self.partial_chunks.insert(key, BytesMut::new()); + // Get the partial chunk we just inserted. + self.partial_chunks + .get_mut(&key) + .expect("we just inserted it") + } + }; + + // We extend the partial chunk with the payload. + // And get the new length of the partial chunk. + let length = { + // If the length of a single chunk is larger than the max partial chunk size + // we return an error. The client is probably trying to DoS us. + if partial_chunk.len() + payload.len() > MAX_PARTIAL_CHUNK_SIZE { + return Err(ChunkDecodeError::PartialChunkTooLarge( + partial_chunk.len() + payload.len(), + )); + } + + // Extend the partial chunk with the payload. + partial_chunk.extend_from_slice(&payload[..]); + + // Return the new length of the partial chunk. + partial_chunk.len() + }; + + // If we have a full chunk we return it. + if length == message_header.msg_length as usize { + return Ok(Some(Chunk { + basic_header: header, + message_header, + payload: self.partial_chunks.remove(&key).unwrap().freeze(), + })); + } + + // If we don't have a full chunk we just let the loop continue. + // Usually this will result in returning Ok(None) from one of the above checks. + // However there is a edge case that we have enough data in our buffer to read the + // next chunk and the client is waiting for us to send a response. Meaning if we just return Ok(None) here + // We would deadlock the connection, and it will eventually timeout. + // So we need to loop again here to check if we have enough data to read the next chunk. + } + } + } + + /// Internal function used to read the basic chunk header. + fn read_header( + &self, + cursor: &mut Cursor<&'_ [u8]>, + ) -> Result> { + // The first byte of the basic header is the format of the chunk and the stream id. + // Mapping the error to none means that this isn't a real error but we dont have enough data. + let byte = cursor.read_u8().map_err(|_| None)?; + // The format is the first 2 bits of the byte. We shift the byte 6 bits to the right to get the format. + let format = (byte >> 6) & 0b00000011; + + // We check that the format is valid. + // Since we do not map to None here, this is a real error and the connection will be closed. + // It should not be possible to get an invalid chunk type because, we bitshift the byte 6 bits to the right. + // Leaving 2 bits which can only be 0, 1 or 2 or 3 which is the only valid chunk types. + let format = + ChunkType::from_u8(format).ok_or(ChunkDecodeError::InvalidChunkType(format))?; + + // We then check the chunk stream id. + let chunk_stream_id = match (byte & 0b00111111) as u32 { + // If the chunk stream id is 0 we read the next byte and add 64 to it. + 0 => 64 + cursor.read_u8().map_err(|_| None)? as u32, + // If it is 1 we read the next 2 bytes and add 64 to it and multiply the 2nd byte by 256. + 1 => { + 64 + cursor.read_u8().map_err(|_| None)? as u32 + + cursor.read_u8().map_err(|_| None)? as u32 * 256 + } + // Any other value means that the chunk stream id is the value of the byte. + csid => csid, + }; + + // We then read the message header. + let header = ChunkBasicHeader { + chunk_stream_id, + format, + }; + + Ok(header) + } + + /// Internal function used to read the message header. + fn read_message_header( + &self, + header: &ChunkBasicHeader, + cursor: &mut Cursor<&'_ [u8]>, + ) -> Result> { + // Each format has a different message header length. + match header.format { + // Type0 headers have the most information and can be compared to keyframes in video. + // They do not reference any previous chunks. They contain the full message header. + ChunkType::Type0 => { + // The first 3 bytes are the timestamp. + let timestamp = cursor.read_u24::().map_err(|_| None)?; + // Followed by a 3 byte message length. (this is the length of the entire payload not just this chunk) + let msg_length = cursor.read_u24::().map_err(|_| None)?; + if msg_length as usize > MAX_PARTIAL_CHUNK_SIZE { + return Err(Some(ChunkDecodeError::PartialChunkTooLarge( + msg_length as usize, + ))); + } + + // We then have a 1 byte message type id. + let msg_type_id = cursor.read_u8().map_err(|_| None)?; + + // We validate the message type id. If it is invalid we return an error. (this is a real error) + let msg_type_id = MessageTypeID::from_u8(msg_type_id) + .ok_or(ChunkDecodeError::InvalidMessageTypeID(msg_type_id))?; + + // We then read the message stream id. (According to spec this is stored in LittleEndian, no idea why.) + let msg_stream_id = cursor.read_u32::().map_err(|_| None)?; + + // Sometimes the timestamp is larger than 3 bytes. + // If the timestamp is 0xFFFFFF we read the next 4 bytes as the timestamp. + // I am not exactly sure why they did it this way. + // Why not just use 3 bytes for the timestamp, and if the 3 bytes are set to 0xFFFFFF just read 1 additional byte and then shift it 24 bits. + // Like if timestamp == 0xFFFFFF { timestamp |= cursor.read_u8().map_err(|_| None)? << 24; } + // This would save 3 bytes in the header and would be more efficient but I guess the Spec writers are smarter than me. + let (timestamp, was_extended_timestamp) = if timestamp == 0xFFFFFF { + // Again this is not a real error, we just dont have enough data. + (cursor.read_u32::().map_err(|_| None)?, true) + } else { + (timestamp, false) + }; + + Ok(ChunkMessageHeader { + timestamp, + msg_length, + msg_type_id, + msg_stream_id, + was_extended_timestamp, + }) + } + // For ChunkType 1 we have a delta timestamp, message length and message type id. + // The message stream id is the same as the previous chunk. + ChunkType::Type1 => { + // The first 3 bytes are the delta timestamp. + let timestamp_delta = cursor.read_u24::().map_err(|_| None)?; + // Followed by a 3 byte message length. (this is the length of the entire payload not just this chunk) + let msg_length = cursor.read_u24::().map_err(|_| None)?; + if msg_length as usize > MAX_PARTIAL_CHUNK_SIZE { + return Err(Some(ChunkDecodeError::PartialChunkTooLarge( + msg_length as usize, + ))); + } + + // We then have a 1 byte message type id. + let msg_type_id = cursor.read_u8().map_err(|_| None)?; + + // We validate the message type id. If it is invalid we return an error. (this is a real error) + let msg_type_id = MessageTypeID::from_u8(msg_type_id) + .ok_or(ChunkDecodeError::InvalidMessageTypeID(msg_type_id))?; + + // Again as mentioned above we sometimes have a delta timestamp larger than 3 bytes. + let (timestamp_delta, was_extended_timestamp) = if timestamp_delta == 0xFFFFFF { + (cursor.read_u32::().map_err(|_| None)?, true) + } else { + (timestamp_delta, false) + }; + + // We get the previous chunk header. + // If the previous chunk header is not found we return an error. (this is a real error) + let previous_header = self + .previous_chunk_headers + .get(&header.chunk_stream_id) + .ok_or(ChunkDecodeError::MissingPreviousChunkHeader( + header.chunk_stream_id, + ))?; + + // We calculate the timestamp by adding the delta timestamp to the previous timestamp. + // We need to make sure this does not overflow. + let timestamp = previous_header + .timestamp + .checked_add(timestamp_delta) + .unwrap_or_else(|| { + tracing::warn!( + "Timestamp overflow detected. Previous timestamp: {}, delta timestamp: {}, using previous timestamp.", + previous_header.timestamp, + timestamp_delta + ); + + previous_header.timestamp + }); + + Ok(ChunkMessageHeader { + timestamp, + msg_length, + msg_type_id, + was_extended_timestamp, + // The message stream id is the same as the previous chunk. + msg_stream_id: previous_header.msg_stream_id, + }) + } + // ChunkType2 headers only have a delta timestamp. + // The message length, message type id and message stream id are the same as the previous chunk. + ChunkType::Type2 => { + // We read the delta timestamp. + let timestamp_delta = cursor.read_u24::().map_err(|_| None)?; + + // Again if the delta timestamp is larger than 3 bytes we read the next 4 bytes as the timestamp. + let (timestamp_delta, was_extended_timestamp) = if timestamp_delta == 0xFFFFFF { + (cursor.read_u32::().map_err(|_| None)?, true) + } else { + (timestamp_delta, false) + }; + + // We get the previous chunk header. + // If the previous chunk header is not found we return an error. (this is a real error) + let previous_header = self + .previous_chunk_headers + .get(&header.chunk_stream_id) + .ok_or(ChunkDecodeError::MissingPreviousChunkHeader( + header.chunk_stream_id, + ))?; + + // We calculate the timestamp by adding the delta timestamp to the previous timestamp. + let timestamp = previous_header.timestamp + timestamp_delta; + + Ok(ChunkMessageHeader { + timestamp, + msg_length: previous_header.msg_length, + msg_type_id: previous_header.msg_type_id, + msg_stream_id: previous_header.msg_stream_id, + was_extended_timestamp, + }) + } + // ChunkType3 headers are the same as the previous chunk header. + ChunkType::Type3 => { + // We get the previous chunk header. + // If the previous chunk header is not found we return an error. (this is a real error) + let previous_header = self + .previous_chunk_headers + .get(&header.chunk_stream_id) + .ok_or(ChunkDecodeError::MissingPreviousChunkHeader( + header.chunk_stream_id, + ))? + .clone(); + + // Now this is truely stupid. + // If the PREVIOUS HEADER is extended then we now waste an additional 4 bytes to read the timestamp. + // Why not just read the timestamp in the previous header if it is extended? + // I guess the spec writers had some reason and its obviously way above my knowledge. + if previous_header.was_extended_timestamp { + // Not a real error, we just dont have enough data. + // We dont have to store this value since it is the same as the previous header. + cursor.read_u32::().map_err(|_| None)?; + } + + Ok(previous_header) + } + } + } + + /// Internal function to get the payload range of a chunk. + fn get_payload_range( + &self, + header: &ChunkBasicHeader, + message_header: &ChunkMessageHeader, + cursor: &mut Cursor<&'_ [u8]>, + ) -> Result<(usize, usize), Option> { + // We find out if the chunk is a partial chunk (and if we have already read some of it). + let key = (header.chunk_stream_id, message_header.msg_stream_id); + + // Check how much we still need to read (if we have already read some of the chunk) + let remaining_read_length = message_header.msg_length as usize + - self + .partial_chunks + .get(&key) + .map(|data| data.len()) + .unwrap_or(0); + + // We get the min between our max chunk size and the remaining read length. + // This is the amount of bytes we need to read. + let need_read_length = min(remaining_read_length, self.max_chunk_size); + + // We get the current position in the cursor. + let pos = cursor.position() as usize; + + // We seek forward to where the payload starts. + cursor + .seek(SeekFrom::Current(need_read_length as i64)) + .map_err(|_| None)?; + + // We then return the range of the payload. + // Which would be the pos to the pos + need_read_length. + Ok((pos, pos + need_read_length)) + } +} diff --git a/video/protocol/rtmp/src/chunk/define.rs b/video/protocol/rtmp/src/chunk/define.rs new file mode 100644 index 00000000..722cfee1 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/define.rs @@ -0,0 +1,104 @@ +use bytes::Bytes; +use num_derive::FromPrimitive; + +use crate::messages::MessageTypeID; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, FromPrimitive, Hash)] +#[repr(u8)] +/// These channel ids are user defined and are not part of the protocol. +/// We just have to send different data on different channels. +/// This is just a easy way to make sure we do not mix up the data. +pub enum DefinedChunkStreamID { + /// ChannelId for sending commands + Command = 3, + /// ChannelId for sending audio + Audio = 4, + /// ChannelId for sending video + Video = 5, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, FromPrimitive, Hash)] +#[repr(u8)] +/// A chunk type represents the format of the chunk header. +pub enum ChunkType { + /// Chunk type 0 - 5.3.1.2.1 + Type0 = 0, + /// Chunk type 1 - 5.3.1.2.2 + Type1 = 1, + /// Chunk type 2 - 5.3.1.2.3 + Type2 = 2, + /// Chunk type 3 - 5.3.1.1.4 + Type3 = 3, +} + +#[derive(Eq, PartialEq, Debug, Clone)] +pub struct ChunkBasicHeader { + /// Used for decoding the header only. + pub(super) format: ChunkType, // 2 bits + + pub chunk_stream_id: u32, // 6 bits (if format == 0, 8 bits, if format == 1, 16 bits) +} + +#[derive(Eq, PartialEq, Debug, Clone)] +pub struct ChunkMessageHeader { + pub timestamp: u32, // 3 bytes (when writing the header, if the timestamp is >= 0xFFFFFF, write 0xFFFFFF) + pub msg_length: u32, // 3 bytes + pub msg_type_id: MessageTypeID, // 1 byte + pub msg_stream_id: u32, // 4 bytes + + pub(super) was_extended_timestamp: bool, // used for reading the header only +} + +impl ChunkMessageHeader { + #[inline] + /// is_extended_timestamp returns true if the timestamp is >= 0xFFFFFF. + /// This means that the timestamp is extended and is written in the extended timestamp field. + pub fn is_extended_timestamp(&self) -> bool { + self.timestamp >= 0xFFFFFF + } +} + +#[derive(Eq, PartialEq, Debug, Clone)] +pub struct Chunk { + pub basic_header: ChunkBasicHeader, + pub message_header: ChunkMessageHeader, + pub payload: Bytes, +} + +impl Chunk { + /// new creates a new chunk. + /// Helper function to create a new chunk. + pub fn new( + chunk_stream_id: u32, + timestamp: u32, + msg_type_id: MessageTypeID, + msg_stream_id: u32, + payload: Bytes, + ) -> Self { + Self { + basic_header: ChunkBasicHeader { + chunk_stream_id, + format: ChunkType::Type0, + }, + message_header: ChunkMessageHeader { + timestamp, + msg_length: payload.len() as u32, + msg_type_id, + msg_stream_id, + was_extended_timestamp: false, + }, + payload, + } + } +} + +/// We bump our chunk size to 4096 bytes. +pub const CHUNK_SIZE: usize = 4096; + +/// Not apart of the spec but we have a limit on how big a chunk can be. +/// This is the maximum chunk size we will accept. If the peer requests a chunk size bigger than this, we will close the connection. +pub const MAX_CHUNK_SIZE: usize = 4096 * 16; // 64 KB + +/// The default chunk size is 128 bytes. +/// 5.4.1 "The maximum chunk size defaults to 128 bytes ..." +pub const INIT_CHUNK_SIZE: usize = 128; diff --git a/video/protocol/rtmp/src/chunk/encoder.rs b/video/protocol/rtmp/src/chunk/encoder.rs new file mode 100644 index 00000000..79dacb6e --- /dev/null +++ b/video/protocol/rtmp/src/chunk/encoder.rs @@ -0,0 +1,123 @@ +use std::io::Write; + +use super::{ + define::{Chunk, ChunkMessageHeader, ChunkType, INIT_CHUNK_SIZE}, + errors::ChunkEncodeError, +}; +use byteorder::{BigEndian, LittleEndian, WriteBytesExt}; +use bytesio::bytes_writer::BytesWriter; + +pub struct ChunkEncoder { + chunk_size: usize, +} + +impl Default for ChunkEncoder { + fn default() -> Self { + Self { + chunk_size: INIT_CHUNK_SIZE, + } + } +} + +impl ChunkEncoder { + pub fn set_chunk_size(&mut self, chunk_size: usize) { + self.chunk_size = chunk_size; + } + + /// Internal function to write the basic header. + fn write_basic_header( + writer: &mut BytesWriter, + fmt: ChunkType, + csid: u32, + ) -> Result<(), ChunkEncodeError> { + let fmt = fmt as u8; + + if csid >= 64 + 255 { + writer.write_u8(fmt << 6 | 1)?; + let csid = csid - 64; + + let div = csid / 256; + let rem = csid % 256; + + writer.write_u8(rem as u8)?; + writer.write_u8(div as u8)?; + } else if csid >= 64 { + writer.write_u8(fmt << 6)?; + writer.write_u8((csid - 64) as u8)?; + } else { + writer.write_u8(fmt << 6 | csid as u8)?; + } + + Ok(()) + } + + fn write_message_header( + writer: &mut BytesWriter, + message_header: &ChunkMessageHeader, + ) -> Result<(), ChunkEncodeError> { + let timestamp = if message_header.timestamp >= 0xFFFFFF { + 0xFFFFFF + } else { + message_header.timestamp + }; + + writer.write_u24::(timestamp)?; + writer.write_u24::(message_header.msg_length)?; + writer.write_u8(message_header.msg_type_id as u8)?; + writer.write_u32::(message_header.msg_stream_id)?; + + if message_header.is_extended_timestamp() { + Self::write_extened_timestamp(writer, message_header.timestamp)?; + } + + Ok(()) + } + + fn write_extened_timestamp( + writer: &mut BytesWriter, + timestamp: u32, + ) -> Result<(), ChunkEncodeError> { + writer.write_u32::(timestamp)?; + + Ok(()) + } + + pub fn write_chunk( + &self, + writer: &mut BytesWriter, + mut chunk_info: Chunk, + ) -> Result<(), ChunkEncodeError> { + Self::write_basic_header( + writer, + ChunkType::Type0, + chunk_info.basic_header.chunk_stream_id, + )?; + + Self::write_message_header(writer, &chunk_info.message_header)?; + + while !chunk_info.payload.is_empty() { + let cur_payload_size = if chunk_info.payload.len() > self.chunk_size { + self.chunk_size + } else { + chunk_info.payload.len() + }; + + let payload_bytes = chunk_info.payload.split_to(cur_payload_size); + writer.write_all(&payload_bytes[..])?; + + if !chunk_info.payload.is_empty() { + Self::write_basic_header( + writer, + ChunkType::Type3, + chunk_info.basic_header.chunk_stream_id, + )?; + + if chunk_info.message_header.is_extended_timestamp() { + Self::write_extened_timestamp(writer, chunk_info.message_header.timestamp)?; + } + } + } + + Ok(()) + } +} diff --git a/video/protocol/rtmp/src/chunk/errors.rs b/video/protocol/rtmp/src/chunk/errors.rs new file mode 100644 index 00000000..247a60b9 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/errors.rs @@ -0,0 +1,60 @@ +use crate::macros::from_error; +use std::{fmt, io}; + +#[derive(Debug)] +pub enum ChunkDecodeError { + IO(io::Error), + InvalidChunkType(u8), + InvalidMessageTypeID(u8), + MissingPreviousChunkHeader(u32), + TooManyPartialChunks, + TooManyPreviousChunkHeaders, + PartialChunkTooLarge(usize), + TimestampOverflow(u32, u32), +} + +from_error!(ChunkDecodeError, Self::IO, io::Error); + +#[derive(Debug)] +pub enum ChunkEncodeError { + UnknownReadState, + IO(io::Error), +} + +from_error!(ChunkEncodeError, Self::IO, io::Error); + +impl fmt::Display for ChunkEncodeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::UnknownReadState => write!(f, "unknown read state"), + Self::IO(err) => write!(f, "io error: {}", err), + } + } +} + +impl fmt::Display for ChunkDecodeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::IO(err) => write!(f, "io error: {}", err), + Self::TooManyPartialChunks => write!(f, "too many partial chunks"), + Self::TooManyPreviousChunkHeaders => write!(f, "too many previous chunk headers"), + Self::PartialChunkTooLarge(size) => write!(f, "partial chunk too large: {}", size), + Self::MissingPreviousChunkHeader(chunk_stream_id) => { + write!(f, "missing previous chunk header: {}", chunk_stream_id) + } + Self::InvalidMessageTypeID(message_type_id) => { + write!(f, "invalid message type id: {}", message_type_id) + } + Self::InvalidChunkType(chunk_type) => { + write!(f, "invalid chunk type: {}", chunk_type) + } + Self::TimestampOverflow(timestamp, delta) => { + write!( + f, + "timestamp overflow: timestamp: {}, delta: {}", + timestamp, delta + ) + } + } + } +} diff --git a/video/protocol/rtmp/src/chunk/mod.rs b/video/protocol/rtmp/src/chunk/mod.rs new file mode 100644 index 00000000..66624f48 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/mod.rs @@ -0,0 +1,14 @@ +mod decoder; +mod define; +mod encoder; +mod errors; + +pub use self::{ + decoder::ChunkDecoder, + define::{Chunk, DefinedChunkStreamID, CHUNK_SIZE}, + encoder::ChunkEncoder, + errors::{ChunkDecodeError, ChunkEncodeError}, +}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/chunk/tests/decoder.rs b/video/protocol/rtmp/src/chunk/tests/decoder.rs new file mode 100644 index 00000000..6129e1b5 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/tests/decoder.rs @@ -0,0 +1,596 @@ +use std::io::Write; + +use byteorder::WriteBytesExt; +use bytesio::bytes_writer::BytesWriter; + +use crate::chunk::{ChunkDecodeError, ChunkDecoder}; + +#[test] +fn test_decoder_error_display() { + let error = ChunkDecodeError::IO(std::io::Error::new(std::io::ErrorKind::Other, "test")); + assert_eq!(format!("{}", error), "io error: test"); + + let error = ChunkDecodeError::InvalidChunkType(123); + assert_eq!(format!("{}", error), "invalid chunk type: 123"); + + let error = ChunkDecodeError::InvalidMessageTypeID(123); + assert_eq!(format!("{}", error), "invalid message type id: 123"); + + let error = ChunkDecodeError::MissingPreviousChunkHeader(123); + assert_eq!(format!("{}", error), "missing previous chunk header: 123"); + + let error = ChunkDecodeError::TooManyPartialChunks; + assert_eq!(format!("{}", error), "too many partial chunks"); + + let error = ChunkDecodeError::TooManyPreviousChunkHeaders; + assert_eq!(format!("{}", error), "too many previous chunk headers"); + + let error = ChunkDecodeError::PartialChunkTooLarge(100); + assert_eq!(format!("{}", error), "partial chunk too large: 100"); + + let error = ChunkDecodeError::TimestampOverflow(100, 200); + assert_eq!( + format!("{}", error), + "timestamp overflow: timestamp: 100, delta: 200" + ); +} + +#[test] +fn test_decoder_chunk_type0_single_sized() { + #[rustfmt::skip] + let mut chunk = vec![ + 3, // chunk type 0, chunk stream id 3 + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x00, 0x80, // message length (128) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + ]; + + for i in 0..128 { + chunk.push(i as u8); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&chunk); + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.timestamp, 0); + assert_eq!(chunk.message_header.msg_length, 128); + assert_eq!(chunk.message_header.msg_stream_id, 0x0100); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 128); +} + +#[test] +fn test_decoder_chunk_type0_double_sized() { + #[rustfmt::skip] + let mut chunk = vec![ + 3, // chunk type 0, chunk stream id 3 + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x01, 0x00, // message length (256) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + ]; + + for i in 0..128 { + chunk.push(i as u8); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&chunk); + + // We should not have enough data to read the chunk + // But the chunk is valid, so we should not get an error + assert!(unpacker.read_chunk().expect("read chunk").is_none()); + + // We just feed the same data again in this test to see if the Unpacker merges the chunks + // Which it should do + unpacker.extend_data(&chunk); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.timestamp, 0); + assert_eq!(chunk.message_header.msg_length, 256); + assert_eq!(chunk.message_header.msg_stream_id, 0x0100); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 256); +} + +#[test] +fn test_decoder_chunk_mutli_streams() { + let mut writer = BytesWriter::default(); + + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x01, 0x00, // message length (256) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + ]) + .unwrap(); + + for _ in 0..128 { + writer.write_u8(3).unwrap(); + } + + #[rustfmt::skip] + writer + .write_all(&[ + 4, // chunk type 0, chunk stream id 4 (different stream) + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x01, 0x00, // message length (256) (max chunk size is set to 128) + 0x08, // message type id (audio) + 0x00, 0x03, 0x00, 0x00, // message stream id + ]) + .unwrap(); + + for _ in 0..128 { + writer.write_u8(4).unwrap(); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + // We wrote 2 chunks but neither of them are complete + assert!(unpacker.read_chunk().expect("read chunk").is_none()); + + #[rustfmt::skip] + writer + .write_all(&[ + (3 << 6) | 4, // chunk type 3, chunk stream id 4 + ]) + .unwrap(); + + for _ in 0..128 { + writer.write_u8(3).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + // Even though we wrote chunk 3 first, chunk 4 should be read first since it's a different stream + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 4); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x08); + assert_eq!(chunk.message_header.timestamp, 0); + assert_eq!(chunk.message_header.msg_length, 256); + assert_eq!(chunk.message_header.msg_stream_id, 0x0300); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 256); + for i in 0..128 { + assert_eq!(chunk.payload[i], 4); + } + + // No chunk is ready yet + assert!(unpacker.read_chunk().expect("read chunk").is_none()); + + #[rustfmt::skip] + writer + .write_all(&[ + (3 << 6) | 3, // chunk type 3, chunk stream id 3 + ]) + .unwrap(); + + for _ in 0..128 { + writer.write_u8(3).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.timestamp, 0); + assert_eq!(chunk.message_header.msg_length, 256); + assert_eq!(chunk.message_header.msg_stream_id, 0x0100); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 256); + for i in 0..128 { + assert_eq!(chunk.payload[i], 3); + } +} + +#[test] +fn test_decoder_extended_timestamp() { + let mut writer = BytesWriter::default(); + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x02, 0x00, // message length (384) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + // We should not have enough data to read the chunk + // But the chunk is valid, so we should not get an error + assert!(unpacker.read_chunk().expect("read chunk").is_none()); + + #[rustfmt::skip] + writer + .write_all(&[ + (1 << 6) | 3, // chunk type 1, chunk stream id 3 + 0xFF, 0xFF, 0xFF, // extended timestamp (again) + 0x00, 0x02, 0x00, // message length (384) (max chunk size is set to 128) + 0x09, // message type id (video) + // message stream id is not present since it's the same as the previous chunk + 0x01, 0x00, 0x00, 0x00, // extended timestamp (again) + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + #[rustfmt::skip] + writer + .write_all(&[ + (2 << 6) | 3, // chunk type 3, chunk stream id 3 + 0x00, 0x00, 0x01, // not extended timestamp + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + #[rustfmt::skip] + writer + .write_all(&[ + (3 << 6) | 3, // chunk type 3, chunk stream id 3 + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.timestamp, 0x02000001); + assert_eq!(chunk.message_header.msg_length, 512); + assert_eq!(chunk.message_header.msg_stream_id, 0x0100); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 512); +} + +#[test] +fn test_decoder_extended_timestamp_ext() { + let mut writer = BytesWriter::default(); + + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x01, 0x00, // message length (256) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + // We should not have enough data to read the chunk + // But the chunk is valid, so we should not get an error + assert!(unpacker.read_chunk().expect("read chunk").is_none()); + + #[rustfmt::skip] + writer + .write_all(&[ + (3 << 6) | 3, // chunk type 1, chunk stream id 3 + 0x00, 0x00, 0x00, 0x00, // extended timestamp this value is ignored + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.timestamp, 0x01000000); + assert_eq!(chunk.message_header.msg_length, 256); + assert_eq!(chunk.message_header.msg_stream_id, 0x0100); // since it's little endian, it's 0x0100 + assert_eq!(chunk.payload.len(), 256); +} + +#[test] +fn test_read_extended_csid() { + let mut writer = BytesWriter::default(); + + #[rustfmt::skip] + writer + .write_all(&[ + (0 << 6), // chunk type 0, chunk stream id 0 + 10, // extended chunk stream id + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x00, 0x00, // message length (256) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + ]) + .unwrap(); + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 64 + 10); +} + +#[test] +fn test_read_extended_csid_ext2() { + let mut writer = BytesWriter::default(); + + #[rustfmt::skip] + writer + .write_all(&[ + 1, // chunk type 0, chunk stream id 0 + 10, // extended chunk stream id + 13, // extended chunk stream id 2 + 0x00, 0x00, 0x00, // timestamp + 0x00, 0x00, 0x00, // message length (256) (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + ]) + .unwrap(); + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("read chunk").expect("chunk"); + + assert_eq!(chunk.basic_header.chunk_stream_id, 64 + 10 + 256 * 13); +} + +#[test] +fn test_decoder_error_no_previous_chunk() { + let mut writer = BytesWriter::default(); + + // Write a chunk with type 3 but no previous chunk + #[rustfmt::skip] + writer + .write_all(&[ + (3 << 6) | 3, // chunk type 0, chunk stream id 3 + ]) + .unwrap(); + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + let err = unpacker.read_chunk().unwrap_err(); + match err { + ChunkDecodeError::MissingPreviousChunkHeader(3) => {} + _ => panic!("Unexpected error: {:?}", err), + } +} + +#[test] +fn test_decoder_error_partial_chunk_too_large() { + let mut writer = BytesWriter::default(); + + // Write a chunk that has a message size that is too large + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0xFF, 0xFF, 0xFF, // timestamp + 0xFF, 0xFF, 0xFF, // message length (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + let err = unpacker.read_chunk().unwrap_err(); + match err { + ChunkDecodeError::PartialChunkTooLarge(16777215) => {} + _ => panic!("Unexpected error: {:?}", err), + } +} + +#[test] +fn test_decoder_error_invalid_message_type_id() { + let mut writer = BytesWriter::default(); + + // Write a chunk with an invalid message type id + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0xFF, 0xFF, 0xFF, // timestamp + 0x08, 0x00, 0x00, // message length (max chunk size is set to 128) + 0xFF, // message type id (invalid) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + let mut unpacker = ChunkDecoder::default(); + unpacker.extend_data(&writer.extract_current_bytes()); + + let err = unpacker.read_chunk().unwrap_err(); + + match err { + ChunkDecodeError::InvalidMessageTypeID(0xFF) => {} + _ => panic!("Unexpected error: {:?}", err), + } +} + +#[test] +fn test_decoder_error_too_many_partial_chunks() { + let mut writer = BytesWriter::default(); + + let mut unpacker = ChunkDecoder::default(); + + for i in 0..4 { + // Write another chunk with a different chunk stream id + #[rustfmt::skip] + writer + .write_all(&[ + (i + 2), // chunk type 0 (partial), chunk stream id i + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x01, 0x00, // message length (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + // Read the chunk + assert!(unpacker + .read_chunk() + .unwrap_or_else(|_| panic!("chunk failed {}", i)) + .is_none()); + } + + // Write another chunk with a different chunk stream id + #[rustfmt::skip] + writer + .write_all(&[ + 12, // chunk type 0, chunk stream id 6 + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x01, 0x00, // message length (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + for i in 0..128 { + writer.write_u8(i as u8).unwrap(); + } + + unpacker.extend_data(&writer.extract_current_bytes()); + + let err = unpacker.read_chunk().unwrap_err(); + match err { + ChunkDecodeError::TooManyPartialChunks => {} + _ => panic!("Unexpected error: {:?}", err), + } +} + +#[test] +fn test_decoder_error_too_many_chunk_headers() { + let mut writer = BytesWriter::default(); + + let mut unpacker = ChunkDecoder::default(); + + for i in 0..100 { + // Write another chunk with a different chunk stream id + #[rustfmt::skip] + writer + .write_all(&[ + (0 << 6), // chunk type 0 (partial), chunk stream id 0 + i, // chunk id + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x00, 0x00, // message length (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + unpacker.extend_data(&writer.extract_current_bytes()); + + // Read the chunk (should be a full chunk since the message length is 0) + assert!(unpacker + .read_chunk() + .unwrap_or_else(|_| panic!("chunk failed {}", i)) + .is_some()); + } + + // Write another chunk with a different chunk stream id + #[rustfmt::skip] + writer + .write_all(&[ + 12, // chunk type 0, chunk stream id 6 + 0xFF, 0xFF, 0xFF, // timestamp + 0x00, 0x00, 0x00, // message length (max chunk size is set to 128) + 0x09, // message type id (video) + 0x00, 0x01, 0x00, 0x00, // message stream id + 0x01, 0x00, 0x00, 0x00, // extended timestamp + ]) + .unwrap(); + + unpacker.extend_data(&writer.extract_current_bytes()); + + let err = unpacker.read_chunk().unwrap_err(); + match err { + ChunkDecodeError::TooManyPreviousChunkHeaders => {} + _ => panic!("Unexpected error: {:?}", err), + } +} + +#[test] +fn test_decoder_larger_chunk_size() { + let mut writer = BytesWriter::default(); + + // Write a chunk that has a message size that is too large + #[rustfmt::skip] + writer + .write_all(&[ + 3, // chunk type 0, chunk stream id 3 + 0x00, 0x00, 0xFF, // timestamp + 0x00, 0x0F, 0x00, // message length () + 0x09, // message type id (video) + 0x01, 0x00, 0x00, 0x00, // message stream id + ]) + .unwrap(); + + for i in 0..3840 { + writer.write_u8(i as u8).unwrap(); + } + + let mut unpacker = ChunkDecoder::default(); + unpacker.update_max_chunk_size(4096); + + unpacker.extend_data(&writer.extract_current_bytes()); + + let chunk = unpacker.read_chunk().expect("failed").expect("chunk"); + assert_eq!(chunk.basic_header.chunk_stream_id, 3); + assert_eq!(chunk.message_header.timestamp, 255); + assert_eq!(chunk.message_header.msg_length, 3840); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x09); + assert_eq!(chunk.message_header.msg_stream_id, 1); // little endian + assert_eq!(chunk.payload.len(), 3840); + + for i in 0..3840 { + assert_eq!(chunk.payload[i], i as u8); + } +} diff --git a/video/protocol/rtmp/src/chunk/tests/encoder.rs b/video/protocol/rtmp/src/chunk/tests/encoder.rs new file mode 100644 index 00000000..98f40e3f --- /dev/null +++ b/video/protocol/rtmp/src/chunk/tests/encoder.rs @@ -0,0 +1,220 @@ +use std::io; + +use bytes::Bytes; +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{Chunk, ChunkEncodeError, ChunkEncoder}, + messages::MessageTypeID, +}; + +#[test] +fn test_encoder_error_display() { + let error = ChunkEncodeError::UnknownReadState; + assert_eq!(format!("{}", error), "unknown read state"); + + let error = ChunkEncodeError::IO(io::Error::from(io::ErrorKind::Other)); + assert_eq!(format!("{}", error), "io error: other error"); +} + +#[test] +fn test_encoder_write_small_chunk() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let chunk = Chunk::new( + 0, + 0, + MessageTypeID::Abort, + 0, + Bytes::from(vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]), + ); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + assert_eq!( + result, + Bytes::from(vec![ + (0x00 << 6), // chunk basic header - fmt: 0, csid: 0 + 0x00, 0x00, 0x00, // timestamp (0) + 0x00, 0x00, 0x08, // message length (8 bytes) + 0x02, // message type id (abort) + 0x00, 0x00, 0x00, 0x00, // message stream id (0) + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // message payload + ]) + ); +} + +#[test] +fn test_encoder_write_large_chunk() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let mut payload = Vec::new(); + for i in 0..129 { + payload.push(i); + } + + let chunk = Chunk::new(10, 100, MessageTypeID::Audio, 13, Bytes::from(payload)); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + let mut expected = vec![ + 0x0A, // chunk basic header - fmt: 0, csid: 10 (the format should have been fixed to 0) + 0x00, 0x00, 0x64, // timestamp (100) + 0x00, 0x00, 0x81, // message length (129 bytes) + 0x08, // message type id (audio) + 0x0D, 0x00, 0x00, 0x00, // message stream id (13) + ]; + + for i in 0..128 { + expected.push(i); + } + + expected.push((0x03 << 6) | 0x0A); // chunk basic header - fmt: 3, csid: 10 + expected.push(128); // The rest of the payload should have been written + + assert_eq!(result, Bytes::from(expected)); +} + +#[test] +fn test_encoder_extended_timestamp() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let chunk = Chunk::new( + 0, + 0xFFFFFFFF, + MessageTypeID::Abort, + 0, + Bytes::from(vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]), + ); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + assert_eq!( + result, + Bytes::from(vec![ + (0x00 << 6), // chunk basic header - fmt: 0, csid: 0 + 0xFF, 0xFF, 0xFF, // timestamp (0xFFFFFF) + 0x00, 0x00, 0x08, // message length (8 bytes) + 0x02, // message type id (abort) + 0x00, 0x00, 0x00, + 0x00, // message stream id (0) + 0xFF, 0xFF, 0xFF, + 0xFF, // extended timestamp (1) + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // message payload + ]) + ); +} + +#[test] +fn test_encoder_extended_timestamp_ext() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let mut payload = Vec::new(); + for i in 0..129 { + payload.push(i); + } + + let chunk = Chunk::new(0, 0xFFFFFFFF, MessageTypeID::Abort, 0, Bytes::from(payload)); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + let mut expected = vec![ + (0x00 << 6), // chunk basic header - fmt: 0, csid: 0 + 0xFF, 0xFF, 0xFF, // timestamp (0xFFFFFF) + 0x00, 0x00, 0x81, // message length (8 bytes) + 0x02, // message type id (abort) + 0x00, 0x00, 0x00, 0x00, // message stream id (0) + 0xFF, 0xFF, 0xFF, 0xFF, // extended timestamp (1) + ]; + + for i in 0..128 { + expected.push(i); + } + + expected.push(0x03 << 6); // chunk basic header - fmt: 3, csid: 0 + expected.extend(vec![0xFF, 0xFF, 0xFF, 0xFF]); // extended timestamp + expected.push(128); // The rest of the payload should have been written + + assert_eq!(result, Bytes::from(expected)); +} + +#[test] +fn test_encoder_extended_csid() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let chunk = Chunk::new( + 64, + 0, + MessageTypeID::Abort, + 0, + Bytes::from(vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]), + ); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + assert_eq!( + result, + Bytes::from(vec![ + (0x00 << 6), // chunk basic header - fmt: 0, csid: 0 + 0x00, // extended csid (64 + 0) = 64 + 0x00, 0x00, 0x00, // timestamp (0) + 0x00, 0x00, 0x08, // message length (8 bytes) + 0x02, // message type id (abort) + 0x00, 0x00, 0x00, 0x00, // message stream id (0) + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // message payload + ]) + ); +} + +#[test] +fn test_encoder_extended_csid_ext() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + let chunk = Chunk::new( + 320, + 0, + MessageTypeID::Abort, + 0, + Bytes::from(vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]), + ); + + encoder.write_chunk(&mut writer, chunk).unwrap(); + + let result = writer.dispose(); + + #[rustfmt::skip] + assert_eq!( + result, + Bytes::from(vec![ + 0x01, // chunk basic header - fmt: 0, csid: 1 + 0x00, // extended csid (64 + 0) = 64 + 0x01, // extended csid (256 * 1) = 256 + 64 + 0 = 320 + 0x00, 0x00, 0x00, // timestamp (0) + 0x00, 0x00, 0x08, // message length (8 bytes) + 0x02, // message type id (abort) + 0x00, 0x00, 0x00, 0x00, // message stream id (0) + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // message payload + ]) + ); +} diff --git a/video/protocol/rtmp/src/chunk/tests/mod.rs b/video/protocol/rtmp/src/chunk/tests/mod.rs new file mode 100644 index 00000000..02c46b08 --- /dev/null +++ b/video/protocol/rtmp/src/chunk/tests/mod.rs @@ -0,0 +1,2 @@ +mod decoder; +mod encoder; diff --git a/video/protocol/rtmp/src/handshake/define.rs b/video/protocol/rtmp/src/handshake/define.rs new file mode 100644 index 00000000..dafb1989 --- /dev/null +++ b/video/protocol/rtmp/src/handshake/define.rs @@ -0,0 +1,72 @@ +use num_derive::FromPrimitive; + +/// The schema version. +/// For the complex handshake the schema is either 0 or 1. +/// A chunk is 764 bytes. (1536 - 8) / 2 = 764 +/// A schema of 0 means the digest is after the key, thus the digest is at offset 776 bytes (768 + 8). +/// A schema of 1 means the digest is before the key thus the offset is at offset 8 bytes (0 + 8). +/// Where 8 bytes is the time and version. (4 bytes each) +/// The schema is determined by the client. +/// The server will always use the schema the client uses. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum SchemaVersion { + Schema0, + Schema1, +} + +/// The RTMP version. +/// We only support version 3. +#[derive(Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(u8)] +pub enum RtmpVersion { + Unknown = 0x0, + Version3 = 0x3, +} + +/// The state of the handshake. +/// This is used to determine what the next step is. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ServerHandshakeState { + ReadC0C1, + WriteS0S1S2, + ReadC2, + Finish, +} + +/// This is the total size of the C1/S1 C2/S2 packets. +pub const RTMP_HANDSHAKE_SIZE: usize = 1536; + +/// This is some magic number, I do not know why its 0x04050001 however, the reference implementation uses this value. +/// https://blog.csdn.net/win_lin/article/details/13006803 +pub const RTMP_SERVER_VERSION: u32 = 0x04050001; + +/// This is the length of the digest. +/// There is a lot of random data before and after the digest, however, the digest is always 32 bytes. +pub const RTMP_DIGEST_LENGTH: usize = 32; + +/// This is the length of the time and version. +/// The time is 4 bytes and the version is 4 bytes. +pub const TIME_VERSION_LENGTH: usize = 8; + +/// This is the length of the chunk. +/// The chunk is 764 bytes. or (1536 - 8) / 2 = 764 +pub const CHUNK_LENGTH: usize = (RTMP_HANDSHAKE_SIZE - TIME_VERSION_LENGTH) / 2; + +/// This is the first half of the server key. +/// Defined https://blog.csdn.net/win_lin/article/details/13006803 +pub const RTMP_SERVER_KEY_FIRST_HALF: &str = "Genuine Adobe Flash Media Server 001"; + +/// This is the first half of the client key. +/// Defined https://blog.csdn.net/win_lin/article/details/13006803 +pub const RTMP_CLIENT_KEY_FIRST_HALF: &str = "Genuine Adobe Flash Player 001"; + +/// This is the second half of the server/client key. +/// Used for the complex handshake. +/// Defined https://blog.csdn.net/win_lin/article/details/13006803 +pub const RTMP_SERVER_KEY: [u8; 68] = [ + 0x47, 0x65, 0x6e, 0x75, 0x69, 0x6e, 0x65, 0x20, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x46, 0x6c, + 0x61, 0x73, 0x68, 0x20, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x20, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x20, 0x30, 0x30, 0x31, 0xf0, 0xee, 0xc2, 0x4a, 0x80, 0x68, 0xbe, 0xe8, 0x2e, 0x00, 0xd0, 0xd1, + 0x02, 0x9e, 0x7e, 0x57, 0x6e, 0xec, 0x5d, 0x2d, 0x29, 0x80, 0x6f, 0xab, 0x93, 0xb8, 0xe6, 0x36, + 0xcf, 0xeb, 0x31, 0xae, +]; diff --git a/video/protocol/rtmp/src/handshake/digest.rs b/video/protocol/rtmp/src/handshake/digest.rs new file mode 100644 index 00000000..57fb8695 --- /dev/null +++ b/video/protocol/rtmp/src/handshake/digest.rs @@ -0,0 +1,121 @@ +use super::{define, define::SchemaVersion, errors::DigestError}; +use bytes::Bytes; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +pub struct DigestProcessor { + data: Bytes, + key: Bytes, +} + +impl DigestProcessor { + pub fn new(data: Bytes, key: Bytes) -> Self { + Self { data, key } + } + + /// Read digest from message + /// According the the spec the schema can either be in the order of + /// time, version, key, digest (schema 0) + /// or + /// time, version, digest, key (schema 1) + pub fn read_digest(&self) -> Result<(Bytes, SchemaVersion), DigestError> { + if let Ok(digest) = self.generate_and_validate(SchemaVersion::Schema0) { + Ok((digest, SchemaVersion::Schema0)) + } else { + let digest = self.generate_and_validate(SchemaVersion::Schema1)?; + Ok((digest, SchemaVersion::Schema1)) + } + } + + pub fn generate_and_fill_digest( + &self, + version: SchemaVersion, + ) -> Result<(Bytes, Bytes, Bytes), DigestError> { + let (left_part, _, right_part) = self.cook_raw_message(version)?; + let computed_digest = self.make_digest(&left_part, &right_part)?; + + // The reason we return 3 parts vs 1 is because if we return 1 part we need to copy the memory + // But this is unnecessary because we are just going to write it into a buffer. + Ok((left_part, computed_digest, right_part)) + } + + fn find_digest_offset(&self, version: SchemaVersion) -> Result { + const OFFSET_LENGTH: usize = 4; + + // in schema 0 the digest is after the key (which is after the time and version) + // in schema 1 the digest is after the time and version + let schema_offset = match version { + SchemaVersion::Schema0 => define::CHUNK_LENGTH + define::TIME_VERSION_LENGTH, + SchemaVersion::Schema1 => define::TIME_VERSION_LENGTH, + }; + + // No idea why this isn't a be u32. + // It seems to be 4 x 8bit values we add together. + // We then mod it by the chunk length - digest length - offset length + // Then add the schema offset and offset length to get the digest offset + Ok((*self.data.get(schema_offset).unwrap() as usize + + *self.data.get(schema_offset + 1).unwrap() as usize + + *self.data.get(schema_offset + 2).unwrap() as usize + + *self.data.get(schema_offset + 3).unwrap() as usize) + % (define::CHUNK_LENGTH - define::RTMP_DIGEST_LENGTH - OFFSET_LENGTH) + + schema_offset + + OFFSET_LENGTH) + } + + fn cook_raw_message( + &self, + version: SchemaVersion, + ) -> Result<(Bytes, Bytes, Bytes), DigestError> { + let digest_offset = self.find_digest_offset(version)?; + + // We split the message into 3 parts: + // 1. The part before the digest + // 2. The digest + // 3. The part after the digest + // This is so we can calculate the digest. + // We then compare it to the digest we read from the message. + // If they are the same we have a valid message. + + // Slice is a O(1) operation and does not copy the memory. + let left_part = self.data.slice(0..digest_offset); + let digest_data = self + .data + .slice(digest_offset..digest_offset + define::RTMP_DIGEST_LENGTH); + let right_part = self + .data + .slice(digest_offset + define::RTMP_DIGEST_LENGTH..); + + Ok((left_part, digest_data, right_part)) + } + + pub fn make_digest(&self, left: &[u8], right: &[u8]) -> Result { + // New hmac from the key + let mut mac = Hmac::::new_from_slice(&self.key[..]).unwrap(); + // Update the hmac with the left and right parts + mac.update(left); + mac.update(right); + + // Finalize the hmac and get the digest + let result = mac.finalize().into_bytes(); + if result.len() != define::RTMP_DIGEST_LENGTH { + return Err(DigestError::DigestLengthNotCorrect); + } + + // This does a copy of the memory but its only 32 bytes so its not a big deal. + Ok(result.to_vec().into()) + } + + fn generate_and_validate(&self, version: SchemaVersion) -> Result { + // We need the 3 parts so we can calculate the digest and compare it to the digest we read from the message. + let (left_part, digest_data, right_part) = self.cook_raw_message(version)?; + + // If the digest we calculated is the same as the digest we read from the message we have a valid message. + if digest_data == self.make_digest(&left_part, &right_part)? { + Ok(digest_data) + } else { + // This does not mean the message is invalid, it just means we need to try the other schema. + // If both schemas fail then the message is invalid and its likely a simple handshake. + Err(DigestError::CannotGenerate) + } + } +} diff --git a/video/protocol/rtmp/src/handshake/errors.rs b/video/protocol/rtmp/src/handshake/errors.rs new file mode 100644 index 00000000..b80b3d36 --- /dev/null +++ b/video/protocol/rtmp/src/handshake/errors.rs @@ -0,0 +1,40 @@ +use std::fmt; + +use crate::macros::from_error; + +#[derive(Debug)] +pub enum HandshakeError { + Digest(DigestError), + IO(std::io::Error), +} + +from_error!(HandshakeError, Self::Digest, DigestError); +from_error!(HandshakeError, Self::IO, std::io::Error); + +impl fmt::Display for HandshakeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Digest(error) => write!(f, "digest error: {}", error), + Self::IO(error) => write!(f, "io error: {}", error), + } + } +} + +#[derive(Debug)] +pub enum DigestError { + NotEnoughData, + DigestLengthNotCorrect, + CannotGenerate, + UnknownSchema, +} + +impl fmt::Display for DigestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::NotEnoughData => write!(f, "not enough data"), + Self::DigestLengthNotCorrect => write!(f, "digest length not correct"), + Self::CannotGenerate => write!(f, "cannot generate digest"), + Self::UnknownSchema => write!(f, "unknown schema"), + } + } +} diff --git a/video/protocol/rtmp/src/handshake/mod.rs b/video/protocol/rtmp/src/handshake/mod.rs new file mode 100644 index 00000000..da4bc5fb --- /dev/null +++ b/video/protocol/rtmp/src/handshake/mod.rs @@ -0,0 +1,14 @@ +mod define; +mod digest; +mod errors; +mod server; +mod utils; + +pub use self::{ + define::{ServerHandshakeState, RTMP_HANDSHAKE_SIZE}, + errors::{DigestError, HandshakeError}, + server::HandshakeServer, +}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/handshake/server.rs b/video/protocol/rtmp/src/handshake/server.rs new file mode 100644 index 00000000..596fb464 --- /dev/null +++ b/video/protocol/rtmp/src/handshake/server.rs @@ -0,0 +1,459 @@ +use std::io::Write; + +use super::{ + define, + define::{RtmpVersion, SchemaVersion, ServerHandshakeState}, + digest::DigestProcessor, + errors::HandshakeError, + utils, +}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{Bytes, BytesMut}; +use bytesio::{bytes_reader::BytesReader, bytes_writer::BytesWriter}; +use rand::Rng; + +// Simple Handshake Server +// RTMP Spec 1.0 - 5.2 +pub struct SimpleHandshakeServer { + version: RtmpVersion, + requested_version: RtmpVersion, + + reader: BytesReader, + + state: ServerHandshakeState, + + c1_bytes: Bytes, + c1_timestamp: u32, +} + +impl Default for SimpleHandshakeServer { + fn default() -> Self { + Self { + reader: BytesReader::new(BytesMut::default()), + state: ServerHandshakeState::ReadC0C1, + c1_bytes: Bytes::new(), + c1_timestamp: 0, + version: RtmpVersion::Unknown, + requested_version: RtmpVersion::Unknown, + } + } +} + +// Complex Handshake Server +// Unfortunately there doesn't seem to be a good spec sheet for this. +// https://blog.csdn.net/win_lin/article/details/13006803 is the best I could find. +pub struct ComplexHandshakeServer { + version: RtmpVersion, + requested_version: RtmpVersion, + + reader: BytesReader, + + state: ServerHandshakeState, + schema_version: SchemaVersion, + + c1_digest: Bytes, + c1_timestamp: u32, + c1_version: u32, +} + +impl Default for ComplexHandshakeServer { + fn default() -> Self { + Self { + reader: BytesReader::new(BytesMut::default()), + state: ServerHandshakeState::ReadC0C1, + c1_digest: Bytes::default(), + c1_timestamp: 0, + version: RtmpVersion::Unknown, + requested_version: RtmpVersion::Unknown, + c1_version: 0, + schema_version: SchemaVersion::Schema0, + } + } +} + +impl SimpleHandshakeServer { + pub fn extend_data(&mut self, data: &[u8]) { + self.reader.extend_from_slice(data); + } + + pub fn handshake(&mut self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + loop { + match self.state { + ServerHandshakeState::ReadC0C1 => { + self.read_c0()?; + self.read_c1()?; + self.state = ServerHandshakeState::WriteS0S1S2; + } + ServerHandshakeState::WriteS0S1S2 => { + self.write_s0(writer)?; + self.write_s1(writer)?; + self.write_s2(writer)?; + self.state = ServerHandshakeState::ReadC2; + break; + } + ServerHandshakeState::ReadC2 => { + self.read_c2()?; + self.state = ServerHandshakeState::Finish; + } + ServerHandshakeState::Finish => { + break; + } + } + } + + Ok(()) + } + + fn read_c0(&mut self) -> Result<(), HandshakeError> { + // Version (8 bits): In C0, this field identifies the RTMP version + // requested by the client. + let requested_version = self.reader.read_u8()?; + self.requested_version = match requested_version { + 3 => RtmpVersion::Version3, + _ => RtmpVersion::Unknown, + }; + + // We only support version 3 for now. + // Therefore we set the version to 3. + self.version = RtmpVersion::Version3; + + Ok(()) + } + + fn read_c1(&mut self) -> Result<(), HandshakeError> { + // Time (4 bytes): This field contains a timestamp, which SHOULD be + // used as the epoch for all future chunks sent from this endpoint. + // This may be 0, or some arbitrary value. To synchronize multiple + // chunkstreams, the endpoint may wish to send the current value of + // the other chunkstream’s timestamp. + self.c1_timestamp = self.reader.read_u32::()?; + + // Zero (4 bytes): This field MUST be all 0s. + self.reader.read_u32::()?; + + // Random data (1528 bytes): This field can contain any arbitrary + // values. Since each endpoint has to distinguish between the + // response to the handshake it has initiated and the handshake + // initiated by its peer,this data SHOULD send something sufficiently + // random. But there is no need for cryptographically-secure + // randomness, or even dynamic values. + self.c1_bytes = self.reader.read_bytes(1528)?.freeze(); + + Ok(()) + } + + fn read_c2(&mut self) -> Result<(), HandshakeError> { + // We don't care too much about the data in C2, so we just read it + // and discard it. + // We should technically check that the timestamp is the same as + // the one we sent in S1, but we don't care. And that the random + // data is the same as the one we sent in S2, but we don't care. + // Some clients are not strict to spec and send different data. + // We can just ignore it and not be super strict. + self.reader.read_bytes(define::RTMP_HANDSHAKE_SIZE)?; + + Ok(()) + } + + /// Defined in RTMP Specification 1.0 - 5.2.2 + fn write_s0(&self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + // Version (8 bits): In S0, this field identifies the RTMP + // version selected by the server. The version defined by this + // specification is 3. A server that does not recognize the + // client’s requested version SHOULD respond with 3. The client MAY + // choose to degrade to version 3, or to abandon the handshake. + writer.write_u8(self.version as u8)?; + + Ok(()) + } + + /// Defined in RTMP Specification 1.0 - 5.2.3 + fn write_s1(&self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + // Time (4 bytes): This field contains a timestamp, which SHOULD be + // used as the epoch for all future chunks sent from this endpoint. + // This may be 0, or some arbitrary value. To synchronize multiple + // chunkstreams, the endpoint may wish to send the current value of + // the other chunkstream’s timestamp. + writer.write_u32::(utils::current_time())?; + + // Zero(4 bytes): This field MUST be all 0s. + writer.write_u32::(0)?; + + // Random data (1528 bytes): This field can contain any arbitrary + // values. Since each endpoint has to distinguish between the + // response to the handshake it has initiated and the handshake + // initiated by its peer,this data SHOULD send something sufficiently + // random. But there is no need for cryptographically-secure + // randomness, or even dynamic values. + let mut rng = rand::thread_rng(); + for _ in 0..1528 { + writer.write_u8(rng.gen())?; + } + + Ok(()) + } + + fn write_s2(&self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + // Time (4 bytes): This field MUST contain the timestamp sent by the C1 (for S2). + writer.write_u32::(self.c1_timestamp)?; + + // Time2 (4 bytes): This field MUST contain the timestamp at which the + // previous packet(s1 or c1) sent by the peer was read. + writer.write_u32::(utils::current_time())?; + + // Random echo (1528 bytes): This field MUST contain the random data + // field sent by the peer in S1 (for C2) or S2 (for C1). Either peer + // can use the time and time2 fields together with the current + // timestamp as a quick estimate of the bandwidth and/or latency of + // the connection, but this is unlikely to be useful. + writer.write_all(&self.c1_bytes[..])?; + + Ok(()) + } +} + +impl ComplexHandshakeServer { + pub fn extend_data(&mut self, data: &[u8]) { + self.reader.extend_from_slice(data); + } + + pub fn handshake(&mut self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + loop { + match self.state { + ServerHandshakeState::ReadC0C1 => { + self.read_c0()?; + self.read_c1()?; + self.state = ServerHandshakeState::WriteS0S1S2; + } + ServerHandshakeState::WriteS0S1S2 => { + self.write_s0(writer)?; + self.write_s1(writer)?; + self.write_s2(writer)?; + self.state = ServerHandshakeState::ReadC2; + break; + } + ServerHandshakeState::ReadC2 => { + self.read_c2()?; + self.state = ServerHandshakeState::Finish; + } + ServerHandshakeState::Finish => { + break; + } + } + } + + Ok(()) + } + + fn read_c0(&mut self) -> Result<(), HandshakeError> { + // Version (8 bits): In C0, this field identifies the RTMP version + // requested by the client. + let requested_version = self.reader.read_u8()?; + self.requested_version = match requested_version { + 3 => RtmpVersion::Version3, + _ => RtmpVersion::Unknown, + }; + + // We only support version 3 for now. + // Therefore we set the version to 3. + self.version = RtmpVersion::Version3; + + Ok(()) + } + + fn read_c1(&mut self) -> Result<(), HandshakeError> { + let c1_bytes = self + .reader + .read_bytes(define::RTMP_HANDSHAKE_SIZE)? + .freeze(); + + // The first 4 bytes of C1 are the timestamp. + self.c1_timestamp = c1_bytes[0..4].as_ref().read_u32::()?; + + // The next 4 bytes are a version number. + self.c1_version = c1_bytes[4..8].as_ref().read_u32::()?; + + // The following 764 bytes are either the digest or the key. + let data_digest = DigestProcessor::new( + c1_bytes, + Bytes::from_static(define::RTMP_CLIENT_KEY_FIRST_HALF.as_bytes()), + ); + + let (c1_digest_data, schema_version) = data_digest.read_digest()?; + + self.c1_digest = c1_digest_data; + self.schema_version = schema_version; + + Ok(()) + } + + fn read_c2(&mut self) -> Result<(), HandshakeError> { + // We don't care too much about the data in C2, so we just read it + // and discard it. + self.reader.read_bytes(define::RTMP_HANDSHAKE_SIZE)?; + + Ok(()) + } + + fn write_s0(&self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + // The version of the protocol used in the handshake. + // This server is using version 3 of the protocol. + writer.write_u8(self.version as u8)?; // 8 bits version + + Ok(()) + } + + fn write_s1(&self, main_writer: &mut BytesWriter) -> Result<(), HandshakeError> { + let mut writer = BytesWriter::default(); + // The first 4 bytes of S1 are the timestamp. + writer.write_u32::(utils::current_time())?; + + // The next 4 bytes are a version number. + writer.write_u32::(define::RTMP_SERVER_VERSION)?; + + // We then write 1528 bytes of random data. (764 bytes for digest, 764 bytes for key) + let mut rng = rand::thread_rng(); + for _ in 0..define::RTMP_HANDSHAKE_SIZE - define::TIME_VERSION_LENGTH { + writer.write_u8(rng.gen())?; + } + + // The digest is loaded with the data that we just generated. + let data_digest = DigestProcessor::new( + writer.dispose(), + Bytes::from_static(define::RTMP_SERVER_KEY_FIRST_HALF.as_bytes()), + ); + + // We use the same schema version as the client. + let (first, second, third) = data_digest.generate_and_fill_digest(self.schema_version)?; + + // We then write the parts of the digest to the main writer. + // Note: this is not a security issue since we do not flush the buffer until we are done + // with the handshake. + main_writer.write_all(&first)?; + main_writer.write_all(&second)?; + main_writer.write_all(&third)?; + + Ok(()) + } + + fn write_s2(&self, main_writer: &mut BytesWriter) -> Result<(), HandshakeError> { + let mut writer = BytesWriter::default(); + + // We write the current time to the first 4 bytes. + writer.write_u32::(utils::current_time())?; + + // We write the timestamp from C1 to the next 4 bytes. + writer.write_u32::(self.c1_timestamp)?; + + // We then write 1528 bytes of random data. (764 bytes for digest, 764 bytes for key) + let mut rng = rand::thread_rng(); + + // define::RTMP_HANDSHAKE_SIZE - define::TIME_VERSION_LENGTH because we already wrote 8 bytes. (timestamp and c1 timestamp) + for _ in 0..define::RTMP_HANDSHAKE_SIZE - define::TIME_VERSION_LENGTH { + writer.write_u8(rng.gen())?; + } + + // The digest is loaded with the data that we just generated. + // This digest is used to generate the key. (digest of c1) + let key_digest = + DigestProcessor::new(Bytes::new(), Bytes::from_static(&define::RTMP_SERVER_KEY)); + + // We then extract the first 1504 bytes of the data. + // define::RTMP_HANDSHAKE_SIZE - 32 = 1504 + // 32 is the size of the digest. for C2S2 + let data = &writer.dispose()[..define::RTMP_HANDSHAKE_SIZE - define::RTMP_DIGEST_LENGTH]; + + // Create a digest of the random data using a key generated from the digest of C1. + let data_digest = + DigestProcessor::new(Bytes::new(), key_digest.make_digest(&self.c1_digest, &[])?); + + // We then generate a digest using the key and the random data + let digest = data_digest.make_digest(data, &[])?; + + // Write the random data to the main writer. + main_writer.write_all(data)?; // 1504 bytes of random data + main_writer.write_all(&digest)?; // 32 bytes of digest + + // Total Write = 1536 bytes (1504 + 32) + + Ok(()) + } +} + +// Order of messages: +// Client -> C0 -> Server +// Client -> C1 -> Server +// Client <- S0 <- Server +// Client <- S1 <- Server +// Client <- S2 <- Server +// Client -> C2 -> Server +pub struct HandshakeServer { + simple_handshaker: SimpleHandshakeServer, + complex_handshaker: ComplexHandshakeServer, + is_complex: bool, + saved_data: BytesMut, +} + +impl Default for HandshakeServer { + fn default() -> Self { + Self { + simple_handshaker: SimpleHandshakeServer::default(), + complex_handshaker: ComplexHandshakeServer::default(), + // We attempt to do a complex handshake by default. If the client does not support it, we fallback to simple. + is_complex: true, + saved_data: BytesMut::default(), + } + } +} + +impl HandshakeServer { + pub fn extend_data(&mut self, data: &[u8]) { + if self.is_complex { + self.complex_handshaker.extend_data(data); + + // We same the data in case we need to switch to simple handshake. + self.saved_data.extend_from_slice(data); + } else { + self.simple_handshaker.extend_data(data); + } + } + + pub fn state(&mut self) -> ServerHandshakeState { + if self.is_complex { + self.complex_handshaker.state + } else { + self.simple_handshaker.state + } + } + + pub fn extract_remaining_bytes(&mut self) -> BytesMut { + if self.is_complex { + self.complex_handshaker.reader.extract_remaining_bytes() + } else { + self.simple_handshaker.reader.extract_remaining_bytes() + } + } + + pub fn handshake(&mut self, writer: &mut BytesWriter) -> Result<(), HandshakeError> { + if self.is_complex { + let result = self.complex_handshaker.handshake(writer); + if result.is_err() { + // Complex handshake failed, switch to simple handshake. + self.is_complex = false; + + // Get the data that was saved in case we need to switch to simple handshake. + let data = self.saved_data.clone(); + + // We then extend the data to the simple handshaker. + self.extend_data(&data[..]); + + // We then perform the handshake. + self.simple_handshaker.handshake(writer)?; + } + } else { + self.simple_handshaker.handshake(writer)?; + } + + Ok(()) + } +} diff --git a/video/protocol/rtmp/src/handshake/tests.rs b/video/protocol/rtmp/src/handshake/tests.rs new file mode 100644 index 00000000..4b2b6418 --- /dev/null +++ b/video/protocol/rtmp/src/handshake/tests.rs @@ -0,0 +1,169 @@ +use std::io::{Cursor, Write}; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use bytes::Bytes; +use bytesio::{bytes_reader::BytesCursor, bytes_writer::BytesWriter}; + +use crate::handshake::{ + define::{self, SchemaVersion}, + digest::DigestProcessor, + errors::DigestError, + ServerHandshakeState, +}; + +use super::{HandshakeError, HandshakeServer}; + +#[test] +fn test_simple_handshake() { + let mut handshake_server = HandshakeServer::default(); + + let mut c0c1 = Cursor::new(Vec::new()); + c0c1.write_u8(3).unwrap(); // version + c0c1.write_u32::(123).unwrap(); // timestamp + c0c1.write_u32::(0).unwrap(); // zero + + let mut write_client_random = vec![0; 1528]; + for (i, v) in write_client_random.iter_mut().enumerate() { + *v = (i % 256) as u8; + } + + c0c1.write_all(&write_client_random).unwrap(); + + handshake_server.extend_data(&c0c1.into_inner()); + + let mut writer = BytesWriter::default(); + handshake_server.handshake(&mut writer).unwrap(); + + let mut reader = Cursor::new(writer.dispose()); + assert_eq!(reader.read_u8().unwrap(), 3); // version + let timestamp = reader.read_u32::().unwrap(); // timestamp + assert_eq!(reader.read_u32::().unwrap(), 0); // zero + + let server_random = reader.read_slice(1528).unwrap(); + + assert_eq!(reader.read_u32::().unwrap(), 123); // our timestamp + let timestamp2 = reader.read_u32::().unwrap(); // server timestamp + + assert!(timestamp2 >= timestamp); + + let read_client_random = reader.read_slice(1528).unwrap(); + + assert_eq!(&write_client_random, &read_client_random); + + let mut c2 = Cursor::new(Vec::new()); + c2.write_u32::(timestamp).unwrap(); // timestamp + c2.write_u32::(124).unwrap(); // our timestamp + c2.write_all(&server_random).unwrap(); + + handshake_server.extend_data(&c2.into_inner()); + + let mut writer = BytesWriter::default(); + handshake_server.handshake(&mut writer).unwrap(); + + assert_eq!(handshake_server.state(), ServerHandshakeState::Finish) +} + +#[test] +fn test_complex_handshake() { + let mut handshake_server = HandshakeServer::default(); + + handshake_server.extend_data(&[3]); // version + + let mut c0c1 = Cursor::new(Vec::new()); + c0c1.write_u32::(123).unwrap(); // timestamp + c0c1.write_u32::(100).unwrap(); // client version + + for i in 0..1528 { + c0c1.write_u8((i % 256) as u8).unwrap(); + } + + let data_digest = DigestProcessor::new( + Bytes::from(c0c1.into_inner()), + Bytes::from_static(define::RTMP_CLIENT_KEY_FIRST_HALF.as_bytes()), + ); + + let (first, second, third) = data_digest + .generate_and_fill_digest(SchemaVersion::Schema1) + .unwrap(); + + // We need to create the digest of the client random + + handshake_server.extend_data(&first); + handshake_server.extend_data(&second); + handshake_server.extend_data(&third); + + let mut writer = BytesWriter::default(); + handshake_server.handshake(&mut writer).unwrap(); + + let bytes = writer.dispose(); + + let s0 = bytes.slice(0..1); + let s1 = bytes.slice(1..1537); + let s2 = bytes.slice(1537..3073); + + assert_eq!(s0[0], 3); // version + assert_ne!((&s1[..4]).read_u32::().unwrap(), 0); // timestamp should not be zero + assert_eq!( + (&s1[4..8]).read_u32::().unwrap(), + define::RTMP_SERVER_VERSION + ); // RTMP version + + let data_digest = DigestProcessor::new( + s1, + Bytes::from_static(define::RTMP_SERVER_KEY_FIRST_HALF.as_bytes()), + ); + + let (digest, schema) = data_digest.read_digest().unwrap(); + assert_eq!(schema, SchemaVersion::Schema1); + + assert_ne!((&s2[..4]).read_u32::().unwrap(), 0); // timestamp should not be zero + assert_eq!((&s2[4..8]).read_u32::().unwrap(), 123); // our timestamp + + let key_digest = + DigestProcessor::new(Bytes::new(), Bytes::from_static(&define::RTMP_SERVER_KEY)); + + let data_digest = + DigestProcessor::new(Bytes::new(), key_digest.make_digest(&second, &[]).unwrap()); + + assert_eq!( + data_digest.make_digest(&s2[..1504], &[]).unwrap(), + s2.slice(1504..) + ); + + let data_digest = + DigestProcessor::new(Bytes::new(), key_digest.make_digest(&digest, &[]).unwrap()); + + let mut c2 = Vec::new(); + for i in 0..1528 { + c2.write_u8((i % 256) as u8).unwrap(); + } + + let digest = data_digest.make_digest(&c2, &[]).unwrap(); + + handshake_server.extend_data(&c2); + handshake_server.extend_data(&digest); + + let mut writer = BytesWriter::default(); + handshake_server.handshake(&mut writer).unwrap(); + + assert_eq!(handshake_server.state(), ServerHandshakeState::Finish) +} + +#[test] +fn test_error_display() { + let err = HandshakeError::Digest(DigestError::CannotGenerate); + assert_eq!(err.to_string(), "digest error: cannot generate digest"); + + let err = HandshakeError::Digest(DigestError::DigestLengthNotCorrect); + assert_eq!(err.to_string(), "digest error: digest length not correct"); + + let err = HandshakeError::Digest(DigestError::UnknownSchema); + assert_eq!(err.to_string(), "digest error: unknown schema"); + + let err = HandshakeError::Digest(DigestError::NotEnoughData); + assert_eq!(err.to_string(), "digest error: not enough data"); + + let err = HandshakeError::IO(Cursor::new(Vec::::new()).read_u8().unwrap_err()); + // no idea why this io error is the error we get but this is mainly testing the display impl anyway + assert_eq!(err.to_string(), "io error: failed to fill whole buffer"); +} diff --git a/video/protocol/rtmp/src/handshake/utils.rs b/video/protocol/rtmp/src/handshake/utils.rs new file mode 100644 index 00000000..a203db7f --- /dev/null +++ b/video/protocol/rtmp/src/handshake/utils.rs @@ -0,0 +1,9 @@ +use std::time::SystemTime; + +pub fn current_time() -> u32 { + let duration = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH); + match duration { + Ok(result) => result.as_nanos() as u32, + _ => 0, + } +} diff --git a/video/protocol/rtmp/src/lib.rs b/video/protocol/rtmp/src/lib.rs new file mode 100644 index 00000000..51979dde --- /dev/null +++ b/video/protocol/rtmp/src/lib.rs @@ -0,0 +1,19 @@ +mod channels; +mod chunk; +mod handshake; +mod macros; +mod messages; +mod netconnection; +mod netstream; +mod protocol_control_messages; +mod session; +mod user_control_messages; + +pub use channels::{ + ChannelData, DataConsumer, DataProducer, PublishConsumer, PublishProducer, PublishRequest, + UniqueID, +}; +pub use session::{Session, SessionError}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/macros.rs b/video/protocol/rtmp/src/macros.rs new file mode 100644 index 00000000..fb12e550 --- /dev/null +++ b/video/protocol/rtmp/src/macros.rs @@ -0,0 +1,11 @@ +macro_rules! from_error { + ($tt:ty, $val:expr, $err:ty) => { + impl From<$err> for $tt { + fn from(error: $err) -> Self { + $val(error) + } + } + }; +} + +pub(super) use from_error; diff --git a/video/protocol/rtmp/src/messages/define.rs b/video/protocol/rtmp/src/messages/define.rs new file mode 100644 index 00000000..a53057d9 --- /dev/null +++ b/video/protocol/rtmp/src/messages/define.rs @@ -0,0 +1,45 @@ +use amf0::Amf0Value; +use bytes::Bytes; +use num_derive::FromPrimitive; + +#[derive(Debug)] +pub enum RtmpMessageData { + Amf0Command { + command_name: Amf0Value, + transaction_id: Amf0Value, + command_object: Amf0Value, + others: Vec, + }, + AmfData { + data: Bytes, + }, + SetChunkSize { + chunk_size: u32, + }, + AudioData { + data: Bytes, + }, + VideoData { + data: Bytes, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, FromPrimitive)] +#[repr(u8)] +pub enum MessageTypeID { + SetChunkSize = 1, + Abort = 2, + Acknowledgement = 3, + UserControlEvent = 4, + WindowAcknowledgementSize = 5, + SetPeerBandwidth = 6, + Audio = 8, + Video = 9, + DataAMF3 = 15, + SharedObjAMF3 = 16, + CommandAMF3 = 17, + DataAMF0 = 18, + SharedObjAMF0 = 19, + CommandAMF0 = 20, + Aggregate = 22, +} diff --git a/video/protocol/rtmp/src/messages/errors.rs b/video/protocol/rtmp/src/messages/errors.rs new file mode 100644 index 00000000..50662b3a --- /dev/null +++ b/video/protocol/rtmp/src/messages/errors.rs @@ -0,0 +1,28 @@ +use amf0::Amf0ReadError; + +use crate::{macros::from_error, protocol_control_messages::ProtocolControlMessageError}; +use std::fmt; + +#[derive(Debug)] +pub enum MessageError { + Amf0Read(Amf0ReadError), + ProtocolControlMessage(ProtocolControlMessageError), +} + +from_error!(MessageError, Self::Amf0Read, Amf0ReadError); +from_error!( + MessageError, + Self::ProtocolControlMessage, + ProtocolControlMessageError +); + +impl fmt::Display for MessageError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self { + Self::Amf0Read(error) => write!(f, "amf0 read error: {}", error), + Self::ProtocolControlMessage(error) => { + write!(f, "protocol control message error: {}", error) + } + } + } +} diff --git a/video/protocol/rtmp/src/messages/mod.rs b/video/protocol/rtmp/src/messages/mod.rs new file mode 100644 index 00000000..7aa5dde8 --- /dev/null +++ b/video/protocol/rtmp/src/messages/mod.rs @@ -0,0 +1,12 @@ +mod define; +mod errors; +mod parser; + +pub use self::{ + define::{MessageTypeID, RtmpMessageData}, + errors::MessageError, + parser::MessageParser, +}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/messages/parser.rs b/video/protocol/rtmp/src/messages/parser.rs new file mode 100644 index 00000000..3bf24a4b --- /dev/null +++ b/video/protocol/rtmp/src/messages/parser.rs @@ -0,0 +1,56 @@ +use amf0::{Amf0Marker, Amf0Reader}; + +use super::{ + define::{MessageTypeID, RtmpMessageData}, + errors::MessageError, +}; +use crate::{chunk::Chunk, protocol_control_messages::ProtocolControlMessageReader}; + +pub struct MessageParser; + +impl MessageParser { + pub fn parse(chunk: Chunk) -> Result, MessageError> { + match chunk.message_header.msg_type_id { + // Protocol Control Messages + MessageTypeID::CommandAMF0 => { + let mut amf_reader = Amf0Reader::new(chunk.payload); + let command_name = amf_reader.read_with_type(Amf0Marker::String)?; + let transaction_id = amf_reader.read_with_type(Amf0Marker::Number)?; + let command_object = match amf_reader.read_with_type(Amf0Marker::Object) { + Ok(val) => val, + Err(_) => amf_reader.read_with_type(Amf0Marker::Null)?, + }; + + let others = amf_reader.read_all()?; + + Ok(Some(RtmpMessageData::Amf0Command { + command_name, + transaction_id, + command_object, + others, + })) + } + // Data Messages - AUDIO + MessageTypeID::Audio => Ok(Some(RtmpMessageData::AudioData { + data: chunk.payload, + })), + // Data Messages - VIDEO + MessageTypeID::Video => Ok(Some(RtmpMessageData::VideoData { + data: chunk.payload, + })), + // Protocol Control Messages + MessageTypeID::SetChunkSize => { + let chunk_size = ProtocolControlMessageReader::read_set_chunk_size(chunk.payload)?; + + Ok(Some(RtmpMessageData::SetChunkSize { chunk_size })) + } + // Metadata + MessageTypeID::DataAMF0 | MessageTypeID::DataAMF3 => { + Ok(Some(RtmpMessageData::AmfData { + data: chunk.payload, + })) + } + _ => Ok(None), + } + } +} diff --git a/video/protocol/rtmp/src/messages/tests.rs b/video/protocol/rtmp/src/messages/tests.rs new file mode 100644 index 00000000..9931ecf4 --- /dev/null +++ b/video/protocol/rtmp/src/messages/tests.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; + +use amf0::{Amf0ReadError, Amf0Value, Amf0Writer}; +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{Chunk, ChunkEncodeError}, + protocol_control_messages::ProtocolControlMessageError, +}; + +use super::{MessageError, MessageParser, MessageTypeID, RtmpMessageData}; + +#[test] +fn test_error_display() { + let error = MessageError::Amf0Read(Amf0ReadError::WrongType); + assert_eq!(error.to_string(), "amf0 read error: wrong type"); + + let error = MessageError::ProtocolControlMessage(ProtocolControlMessageError::ChunkEncode( + ChunkEncodeError::UnknownReadState, + )); + assert_eq!( + error.to_string(), + "protocol control message error: chunk encode error: unknown read state" + ); +} + +#[test] +fn test_parse_command() { + let mut amf0_writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut amf0_writer, "connect").unwrap(); + Amf0Writer::write_number(&mut amf0_writer, 1.0).unwrap(); + Amf0Writer::write_null(&mut amf0_writer).unwrap(); + + let chunk = Chunk::new(0, 0, MessageTypeID::CommandAMF0, 0, amf0_writer.dispose()); + + let message = MessageParser::parse(chunk) + .expect("no errors") + .expect("message"); + match message { + RtmpMessageData::Amf0Command { + command_name, + transaction_id, + command_object, + others, + } => { + assert_eq!(command_name, Amf0Value::String("connect".to_string())); + assert_eq!(transaction_id, Amf0Value::Number(1.0)); + assert_eq!(command_object, Amf0Value::Null); + assert_eq!(others, vec![]); + } + _ => unreachable!("wrong message type"), + } +} + +#[test] +fn test_parse_audio_packet() { + let chunk = Chunk::new( + 0, + 0, + MessageTypeID::Audio, + 0, + vec![0x00, 0x00, 0x00, 0x00].into(), + ); + + let message = MessageParser::parse(chunk) + .expect("no errors") + .expect("message"); + match message { + RtmpMessageData::AudioData { data } => { + assert_eq!(data, vec![0x00, 0x00, 0x00, 0x00]); + } + _ => unreachable!("wrong message type"), + } +} + +#[test] +fn test_parse_video_packet() { + let chunk = Chunk::new( + 0, + 0, + MessageTypeID::Video, + 0, + vec![0x00, 0x00, 0x00, 0x00].into(), + ); + + let message = MessageParser::parse(chunk) + .expect("no errors") + .expect("message"); + match message { + RtmpMessageData::VideoData { data } => { + assert_eq!(data, vec![0x00, 0x00, 0x00, 0x00]); + } + _ => unreachable!("wrong message type"), + } +} + +#[test] +fn test_parse_set_chunk_size() { + let chunk = Chunk::new( + 0, + 0, + MessageTypeID::SetChunkSize, + 0, + vec![0x00, 0xFF, 0xFF, 0xFF].into(), + ); + + let message = MessageParser::parse(chunk) + .expect("no errors") + .expect("message"); + match message { + RtmpMessageData::SetChunkSize { chunk_size } => { + assert_eq!(chunk_size, 0x00FFFFFF); + } + _ => unreachable!("wrong message type"), + } +} + +#[test] +fn test_parse_metadata() { + let mut amf0_writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut amf0_writer, "onMetaData").unwrap(); + Amf0Writer::write_object( + &mut amf0_writer, + &HashMap::from([("duration".to_string(), Amf0Value::Number(0.0))]), + ) + .unwrap(); + + let amf_data = amf0_writer.dispose(); + + let chunk = Chunk::new(0, 0, MessageTypeID::DataAMF0, 0, amf_data.clone()); + + let message = MessageParser::parse(chunk) + .expect("no errors") + .expect("message"); + match message { + RtmpMessageData::AmfData { data } => { + assert_eq!(data, amf_data); + } + _ => unreachable!("wrong message type"), + } +} + +#[test] +fn test_unsupported_message_type() { + let chunk = Chunk::new( + 0, + 0, + MessageTypeID::Aggregate, + 0, + vec![0x00, 0x00, 0x00, 0x00].into(), + ); + + assert!(MessageParser::parse(chunk).expect("no errors").is_none()) +} diff --git a/video/protocol/rtmp/src/netconnection/errors.rs b/video/protocol/rtmp/src/netconnection/errors.rs new file mode 100644 index 00000000..e9ee0bd0 --- /dev/null +++ b/video/protocol/rtmp/src/netconnection/errors.rs @@ -0,0 +1,22 @@ +use amf0::Amf0WriteError; + +use crate::{chunk::ChunkEncodeError, macros::from_error}; +use std::fmt; + +#[derive(Debug)] +pub enum NetConnectionError { + Amf0Write(Amf0WriteError), + ChunkEncode(ChunkEncodeError), +} + +from_error!(NetConnectionError, Self::Amf0Write, Amf0WriteError); +from_error!(NetConnectionError, Self::ChunkEncode, ChunkEncodeError); + +impl fmt::Display for NetConnectionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Amf0Write(err) => write!(f, "amf0 write error: {}", err), + Self::ChunkEncode(err) => write!(f, "chunk encode error: {}", err), + } + } +} diff --git a/video/protocol/rtmp/src/netconnection/mod.rs b/video/protocol/rtmp/src/netconnection/mod.rs new file mode 100644 index 00000000..ad5a67c4 --- /dev/null +++ b/video/protocol/rtmp/src/netconnection/mod.rs @@ -0,0 +1,7 @@ +mod errors; +mod writer; + +pub use self::{errors::NetConnectionError, writer::NetConnection}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/netconnection/tests.rs b/video/protocol/rtmp/src/netconnection/tests.rs new file mode 100644 index 00000000..087b0c86 --- /dev/null +++ b/video/protocol/rtmp/src/netconnection/tests.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use amf0::{Amf0Reader, Amf0Value, Amf0WriteError}; +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{ChunkDecoder, ChunkEncodeError, ChunkEncoder}, + netconnection::NetConnectionError, +}; + +use super::NetConnection; + +#[test] +fn test_error_display() { + let error = NetConnectionError::Amf0Write(Amf0WriteError::NormalStringTooLong); + assert_eq!( + error.to_string(), + "amf0 write error: normal string too long" + ); + + let error = NetConnectionError::ChunkEncode(ChunkEncodeError::UnknownReadState); + assert_eq!(error.to_string(), "chunk encode error: unknown read state"); +} + +#[test] +fn test_netconnection_connect_response() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + NetConnection::write_connect_response( + &encoder, + &mut writer, + 1.0, + "flashver", + 31.0, + "status", + "idk", + "description", + 0.0, + ) + .unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x03); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x14); + assert_eq!(chunk.message_header.msg_stream_id, 0); + + let mut amf0_reader = Amf0Reader::new(chunk.payload); + let values = amf0_reader.read_all().unwrap(); + + assert_eq!(values.len(), 4); + assert_eq!(values[0], Amf0Value::String("_result".to_string())); // command name + assert_eq!(values[1], Amf0Value::Number(1.0)); // transaction id + assert_eq!( + values[2], + Amf0Value::Object(HashMap::from([ + ( + "fmsVer".to_string(), + Amf0Value::String("flashver".to_string()) + ), + ("capabilities".to_string(), Amf0Value::Number(31.0)), + ])) + ); // command object + assert_eq!( + values[3], + Amf0Value::Object(HashMap::from([ + ("code".to_string(), Amf0Value::String("status".to_string())), + ("level".to_string(), Amf0Value::String("idk".to_string())), + ( + "description".to_string(), + Amf0Value::String("description".to_string()) + ), + ("objectEncoding".to_string(), Amf0Value::Number(0.0)), + ])) + ); // info object +} + +#[test] +fn test_netconnection_create_stream_response() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + NetConnection::write_create_stream_response(&encoder, &mut writer, 1.0, 1.0).unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x03); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x14); + assert_eq!(chunk.message_header.msg_stream_id, 0); + + let mut amf0_reader = Amf0Reader::new(chunk.payload); + let values = amf0_reader.read_all().unwrap(); + + assert_eq!(values.len(), 4); + assert_eq!(values[0], Amf0Value::String("_result".to_string())); // command name + assert_eq!(values[1], Amf0Value::Number(1.0)); // transaction id + assert_eq!(values[2], Amf0Value::Null); // command object + assert_eq!(values[3], Amf0Value::Number(1.0)); // stream id +} diff --git a/video/protocol/rtmp/src/netconnection/writer.rs b/video/protocol/rtmp/src/netconnection/writer.rs new file mode 100644 index 00000000..158247f5 --- /dev/null +++ b/video/protocol/rtmp/src/netconnection/writer.rs @@ -0,0 +1,88 @@ +use super::errors::NetConnectionError; +use crate::{ + chunk::{Chunk, ChunkEncoder, DefinedChunkStreamID}, + messages::MessageTypeID, +}; +use amf0::{Amf0Value, Amf0Writer}; +use bytesio::bytes_writer::BytesWriter; +use std::collections::HashMap; + +pub struct NetConnection; + +impl NetConnection { + fn write_chunk( + encoder: &ChunkEncoder, + amf0: BytesWriter, + writer: &mut BytesWriter, + ) -> Result<(), NetConnectionError> { + let data = amf0.dispose(); + + encoder.write_chunk( + writer, + Chunk::new( + DefinedChunkStreamID::Command as u32, + 0, + MessageTypeID::CommandAMF0, + 0, + data, + ), + )?; + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn write_connect_response( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + transaction_id: f64, + fmsver: &str, + capabilities: f64, + code: &str, + level: &str, + description: &str, + encoding: f64, + ) -> Result<(), NetConnectionError> { + let mut amf0_writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut amf0_writer, "_result")?; + Amf0Writer::write_number(&mut amf0_writer, transaction_id)?; + Amf0Writer::write_object( + &mut amf0_writer, + &HashMap::from([ + ("fmsVer".to_string(), Amf0Value::String(fmsver.to_string())), + ("capabilities".to_string(), Amf0Value::Number(capabilities)), + ]), + )?; + Amf0Writer::write_object( + &mut amf0_writer, + &HashMap::from([ + ("level".to_string(), Amf0Value::String(level.to_string())), + ("code".to_string(), Amf0Value::String(code.to_string())), + ( + "description".to_string(), + Amf0Value::String(description.to_string()), + ), + ("objectEncoding".to_string(), Amf0Value::Number(encoding)), + ]), + )?; + + Self::write_chunk(encoder, amf0_writer, writer) + } + + pub fn write_create_stream_response( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + transaction_id: f64, + stream_id: f64, + ) -> Result<(), NetConnectionError> { + let mut amf0_writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut amf0_writer, "_result")?; + Amf0Writer::write_number(&mut amf0_writer, transaction_id)?; + Amf0Writer::write_null(&mut amf0_writer)?; + Amf0Writer::write_number(&mut amf0_writer, stream_id)?; + + Self::write_chunk(encoder, amf0_writer, writer) + } +} diff --git a/video/protocol/rtmp/src/netstream/errors.rs b/video/protocol/rtmp/src/netstream/errors.rs new file mode 100644 index 00000000..17de21f7 --- /dev/null +++ b/video/protocol/rtmp/src/netstream/errors.rs @@ -0,0 +1,24 @@ +use amf0::Amf0WriteError; + +use crate::{chunk::ChunkEncodeError, macros::from_error}; +use std::fmt; + +#[derive(Debug)] +pub enum NetStreamError { + Amf0Write(Amf0WriteError), + ChunkEncode(ChunkEncodeError), +} + +from_error!(NetStreamError, Self::Amf0Write, Amf0WriteError); +from_error!(NetStreamError, Self::ChunkEncode, ChunkEncodeError); + +impl fmt::Display for NetStreamError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Amf0Write(error) => { + write!(f, "amf0 write error: {}", error) + } + Self::ChunkEncode(error) => write!(f, "chunk encode error: {}", error), + } + } +} diff --git a/video/protocol/rtmp/src/netstream/mod.rs b/video/protocol/rtmp/src/netstream/mod.rs new file mode 100644 index 00000000..c67cd761 --- /dev/null +++ b/video/protocol/rtmp/src/netstream/mod.rs @@ -0,0 +1,7 @@ +mod errors; +mod writer; + +pub use self::{errors::NetStreamError, writer::NetStreamWriter}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/netstream/tests.rs b/video/protocol/rtmp/src/netstream/tests.rs new file mode 100644 index 00000000..7640d5e2 --- /dev/null +++ b/video/protocol/rtmp/src/netstream/tests.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; + +use amf0::{Amf0Reader, Amf0Value, Amf0WriteError}; +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{ChunkDecoder, ChunkEncodeError, ChunkEncoder}, + netstream::{NetStreamError, NetStreamWriter}, +}; + +#[test] +fn test_error_display() { + let error = NetStreamError::Amf0Write(Amf0WriteError::NormalStringTooLong); + assert_eq!( + error.to_string(), + "amf0 write error: normal string too long" + ); + + let error = NetStreamError::ChunkEncode(ChunkEncodeError::UnknownReadState); + assert_eq!(error.to_string(), "chunk encode error: unknown read state"); +} + +#[test] +fn test_netstream_write_on_status() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + NetStreamWriter::write_on_status(&encoder, &mut writer, 1.0, "status", "idk", "description") + .unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x03); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x14); + assert_eq!(chunk.message_header.msg_stream_id, 0); + + let mut amf0_reader = Amf0Reader::new(chunk.payload); + let values = amf0_reader.read_all().unwrap(); + + assert_eq!(values.len(), 4); + assert_eq!(values[0], Amf0Value::String("onStatus".to_string())); // command name + assert_eq!(values[1], Amf0Value::Number(1.0)); // transaction id + assert_eq!(values[2], Amf0Value::Null); // command object + assert_eq!( + values[3], + Amf0Value::Object(HashMap::from([ + ("code".to_string(), Amf0Value::String("idk".to_string())), + ("level".to_string(), Amf0Value::String("status".to_string())), + ( + "description".to_string(), + Amf0Value::String("description".to_string()) + ), + ])) + ); // info object +} diff --git a/video/protocol/rtmp/src/netstream/writer.rs b/video/protocol/rtmp/src/netstream/writer.rs new file mode 100644 index 00000000..7fe49da0 --- /dev/null +++ b/video/protocol/rtmp/src/netstream/writer.rs @@ -0,0 +1,61 @@ +use super::errors::NetStreamError; +use crate::{ + chunk::{Chunk, ChunkEncoder, DefinedChunkStreamID}, + messages::MessageTypeID, +}; +use amf0::{Amf0Value, Amf0Writer}; +use bytesio::bytes_writer::BytesWriter; +use std::collections::HashMap; + +pub struct NetStreamWriter {} + +impl NetStreamWriter { + fn write_chunk( + encoder: &ChunkEncoder, + amf0_writer: BytesWriter, + writer: &mut BytesWriter, + ) -> Result<(), NetStreamError> { + let data = amf0_writer.dispose(); + + encoder.write_chunk( + writer, + Chunk::new( + DefinedChunkStreamID::Command as u32, + 0, + MessageTypeID::CommandAMF0, + 0, + data, + ), + )?; + + Ok(()) + } + + pub fn write_on_status( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + transaction_id: f64, + level: &str, + code: &str, + description: &str, + ) -> Result<(), NetStreamError> { + let mut amf0_writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut amf0_writer, "onStatus")?; + Amf0Writer::write_number(&mut amf0_writer, transaction_id)?; + Amf0Writer::write_null(&mut amf0_writer)?; + Amf0Writer::write_object( + &mut amf0_writer, + &HashMap::from([ + ("level".to_string(), Amf0Value::String(level.to_string())), + ("code".to_string(), Amf0Value::String(code.to_string())), + ( + "description".to_string(), + Amf0Value::String(description.to_string()), + ), + ]), + )?; + + Self::write_chunk(encoder, amf0_writer, writer) + } +} diff --git a/video/protocol/rtmp/src/protocol_control_messages/errors.rs b/video/protocol/rtmp/src/protocol_control_messages/errors.rs new file mode 100644 index 00000000..41601d73 --- /dev/null +++ b/video/protocol/rtmp/src/protocol_control_messages/errors.rs @@ -0,0 +1,25 @@ +use std::io; + +use crate::{chunk::ChunkEncodeError, macros::from_error}; + +#[derive(Debug)] +pub enum ProtocolControlMessageError { + IO(io::Error), + ChunkEncode(ChunkEncodeError), +} + +from_error!(ProtocolControlMessageError, Self::IO, io::Error); +from_error!( + ProtocolControlMessageError, + Self::ChunkEncode, + ChunkEncodeError +); + +impl std::fmt::Display for ProtocolControlMessageError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::IO(e) => write!(f, "io error: {}", e), + Self::ChunkEncode(e) => write!(f, "chunk encode error: {}", e), + } + } +} diff --git a/video/protocol/rtmp/src/protocol_control_messages/mod.rs b/video/protocol/rtmp/src/protocol_control_messages/mod.rs new file mode 100644 index 00000000..3f7098f2 --- /dev/null +++ b/video/protocol/rtmp/src/protocol_control_messages/mod.rs @@ -0,0 +1,11 @@ +mod errors; +mod reader; +mod writer; + +pub use self::{ + errors::ProtocolControlMessageError, reader::ProtocolControlMessageReader, + writer::ProtocolControlMessagesWriter, +}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/protocol_control_messages/reader.rs b/video/protocol/rtmp/src/protocol_control_messages/reader.rs new file mode 100644 index 00000000..966a6776 --- /dev/null +++ b/video/protocol/rtmp/src/protocol_control_messages/reader.rs @@ -0,0 +1,17 @@ +use std::io::Cursor; + +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::Bytes; + +use super::errors::ProtocolControlMessageError; + +pub struct ProtocolControlMessageReader; + +impl ProtocolControlMessageReader { + pub fn read_set_chunk_size(data: Bytes) -> Result { + let mut cursor = Cursor::new(data); + let chunk_size = cursor.read_u32::()?; + + Ok(chunk_size) + } +} diff --git a/video/protocol/rtmp/src/protocol_control_messages/tests.rs b/video/protocol/rtmp/src/protocol_control_messages/tests.rs new file mode 100644 index 00000000..5d7c48fb --- /dev/null +++ b/video/protocol/rtmp/src/protocol_control_messages/tests.rs @@ -0,0 +1,76 @@ +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{ChunkDecoder, ChunkEncodeError, ChunkEncoder}, + protocol_control_messages::{ + ProtocolControlMessageError, ProtocolControlMessageReader, ProtocolControlMessagesWriter, + }, +}; + +#[test] +fn test_error_display() { + let error = ProtocolControlMessageError::ChunkEncode(ChunkEncodeError::UnknownReadState); + assert_eq!(error.to_string(), "chunk encode error: unknown read state"); + + let error = ProtocolControlMessageError::IO(std::io::Error::from(std::io::ErrorKind::Other)); + assert_eq!(error.to_string(), "io error: other error"); +} + +#[test] +fn test_reader_read_set_chunk_size() { + let data = vec![0x00, 0x00, 0x00, 0x01]; + let chunk_size = ProtocolControlMessageReader::read_set_chunk_size(data.into()).unwrap(); + assert_eq!(chunk_size, 1); +} + +#[test] +fn test_writer_write_set_chunk_size() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + ProtocolControlMessagesWriter::write_set_chunk_size(&encoder, &mut writer, 1).unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x02); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x01); + assert_eq!(chunk.message_header.msg_stream_id, 0); + assert_eq!(chunk.payload, vec![0x00, 0x00, 0x00, 0x01]); +} + +#[test] +fn test_writer_window_acknowledgement_size() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + ProtocolControlMessagesWriter::write_window_acknowledgement_size(&encoder, &mut writer, 1) + .unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x02); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x05); + assert_eq!(chunk.message_header.msg_stream_id, 0); + assert_eq!(chunk.payload, vec![0x00, 0x00, 0x00, 0x01]); +} + +#[test] +fn test_writer_set_peer_bandwidth() { + let encoder = ChunkEncoder::default(); + let mut writer = BytesWriter::default(); + + ProtocolControlMessagesWriter::write_set_peer_bandwidth(&encoder, &mut writer, 1, 2).unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x02); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x06); + assert_eq!(chunk.message_header.msg_stream_id, 0); + assert_eq!(chunk.payload, vec![0x00, 0x00, 0x00, 0x01, 0x02]); +} diff --git a/video/protocol/rtmp/src/protocol_control_messages/writer.rs b/video/protocol/rtmp/src/protocol_control_messages/writer.rs new file mode 100644 index 00000000..d375f89e --- /dev/null +++ b/video/protocol/rtmp/src/protocol_control_messages/writer.rs @@ -0,0 +1,81 @@ +use crate::{ + chunk::{Chunk, ChunkEncoder}, + messages::MessageTypeID, +}; + +use byteorder::{BigEndian, WriteBytesExt}; +use bytes::Bytes; +use bytesio::bytes_writer::BytesWriter; + +use super::errors::ProtocolControlMessageError; + +pub struct ProtocolControlMessagesWriter; + +impl ProtocolControlMessagesWriter { + pub fn write_set_chunk_size( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + chunk_size: u32, // 31 bits + ) -> Result<(), ProtocolControlMessageError> { + // According to spec the first bit must be 0. + let chunk_size = chunk_size & 0x7FFFFFFF; // 31 bits only + + encoder.write_chunk( + writer, + Chunk::new( + 2, // chunk stream must be 2 + 0, // timestamps are ignored + MessageTypeID::SetChunkSize, + 0, // message stream id is ignored + Bytes::from(chunk_size.to_be_bytes().to_vec()), + ), + )?; + + Ok(()) + } + + pub fn write_window_acknowledgement_size( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + window_size: u32, + ) -> Result<(), ProtocolControlMessageError> { + encoder.write_chunk( + writer, + Chunk::new( + 2, // chunk stream must be 2 + 0, // timestamps are ignored + MessageTypeID::WindowAcknowledgementSize, + 0, // message stream id is ignored + Bytes::from(window_size.to_be_bytes().to_vec()), + ), + )?; + + Ok(()) + } + + pub fn write_set_peer_bandwidth( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + window_size: u32, + limit_type: u8, + ) -> Result<(), ProtocolControlMessageError> { + let mut data = Vec::new(); + data.write_u32::(window_size) + .expect("Failed to write window size"); + data.write_u8(limit_type) + .expect("Failed to write limit type"); + + encoder.write_chunk( + writer, + Chunk::new( + 2, // chunk stream must be 2 + 0, // timestamps are ignored + MessageTypeID::SetPeerBandwidth, + 0, // message stream id is ignored + Bytes::from(data), + ), + )?; + + Ok(()) + } +} diff --git a/video/protocol/rtmp/src/session/define.rs b/video/protocol/rtmp/src/session/define.rs new file mode 100644 index 00000000..0269aece --- /dev/null +++ b/video/protocol/rtmp/src/session/define.rs @@ -0,0 +1,36 @@ +#[derive(Debug, PartialEq, Eq, Clone)] + +/// RTMP Commands are defined in the RTMP specification +pub(super) enum RtmpCommand { + /// NetConnection.connect + Connect, + /// NetConnection.createStream + CreateStream, + /// NetStream.publish + Publish, + /// NetStream.play + Play, + /// NetStream.deleteStream + DeleteStream, + /// NetStream.closeStream + CloseStream, + /// NetStream.releaseStream + ReleaseStream, + /// Unknown command + Unknown(String), +} + +impl From<&str> for RtmpCommand { + fn from(command: &str) -> Self { + match command { + "connect" => Self::Connect, + "createStream" => Self::CreateStream, + "deleteStream" => Self::DeleteStream, + "publish" => Self::Publish, + "play" => Self::Play, + "closeStream" => Self::CloseStream, + "releaseStream" => Self::ReleaseStream, + _ => Self::Unknown(command.to_string()), + } + } +} diff --git a/video/protocol/rtmp/src/session/errors.rs b/video/protocol/rtmp/src/session/errors.rs new file mode 100644 index 00000000..c506c7fc --- /dev/null +++ b/video/protocol/rtmp/src/session/errors.rs @@ -0,0 +1,70 @@ +use std::fmt; + +use bytesio::bytesio_errors::BytesIOError; + +use crate::{ + channels::UniqueID, chunk::ChunkDecodeError, handshake::HandshakeError, macros::from_error, + messages::MessageError, netconnection::NetConnectionError, netstream::NetStreamError, + protocol_control_messages::ProtocolControlMessageError, + user_control_messages::EventMessagesError, +}; + +#[derive(Debug)] +pub enum SessionError { + BytesIO(BytesIOError), + Handshake(HandshakeError), + Message(MessageError), + ChunkDecode(ChunkDecodeError), + ProtocolControlMessage(ProtocolControlMessageError), + NetStream(NetStreamError), + NetConnection(NetConnectionError), + EventMessages(EventMessagesError), + UnknownStreamID(u32), + PublisherDisconnected(UniqueID), + NoAppName, + NoStreamName, + PublishRequestDenied, + ConnectRequestDenied, + PlayNotSupported, + PublisherDropped, + InvalidChunkSize(usize), +} + +from_error!(SessionError, Self::BytesIO, BytesIOError); +from_error!(SessionError, Self::Handshake, HandshakeError); +from_error!(SessionError, Self::Message, MessageError); +from_error!(SessionError, Self::ChunkDecode, ChunkDecodeError); +from_error!( + SessionError, + Self::ProtocolControlMessage, + ProtocolControlMessageError +); +from_error!(SessionError, Self::NetStream, NetStreamError); +from_error!(SessionError, Self::NetConnection, NetConnectionError); +from_error!(SessionError, Self::EventMessages, EventMessagesError); + +impl fmt::Display for SessionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::BytesIO(error) => write!(f, "bytesio error: {}", error), + Self::Handshake(error) => write!(f, "handshake error: {}", error), + Self::Message(error) => write!(f, "message error: {}", error), + Self::ChunkDecode(error) => write!(f, "chunk decode error: {}", error), + Self::ProtocolControlMessage(error) => { + write!(f, "protocol control message error: {}", error) + } + Self::NetStream(error) => write!(f, "netstream error: {}", error), + Self::NetConnection(error) => write!(f, "netconnection error: {}", error), + Self::EventMessages(error) => write!(f, "event messages error: {}", error), + Self::UnknownStreamID(id) => write!(f, "unknown stream id: {}", id), + Self::PublisherDisconnected(name) => write!(f, "publisher disconnected: {}", name), + Self::NoAppName => write!(f, "no app name"), + Self::NoStreamName => write!(f, "no stream name"), + Self::PublishRequestDenied => write!(f, "publish request denied"), + Self::ConnectRequestDenied => write!(f, "connect request denied"), + Self::InvalidChunkSize(size) => write!(f, "invalid chunk size: {}", size), + Self::PlayNotSupported => write!(f, "play not supported"), + Self::PublisherDropped => write!(f, "publisher dropped"), + } + } +} diff --git a/video/protocol/rtmp/src/session/mod.rs b/video/protocol/rtmp/src/session/mod.rs new file mode 100644 index 00000000..de1089dc --- /dev/null +++ b/video/protocol/rtmp/src/session/mod.rs @@ -0,0 +1,8 @@ +mod define; +mod errors; +mod server_session; + +pub use self::{errors::SessionError, server_session::Session}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/session/server_session.rs b/video/protocol/rtmp/src/session/server_session.rs new file mode 100644 index 00000000..38d996a1 --- /dev/null +++ b/video/protocol/rtmp/src/session/server_session.rs @@ -0,0 +1,526 @@ +use super::{define::RtmpCommand, errors::SessionError}; +use crate::{ + channels::{ChannelData, DataProducer, PublishRequest, UniqueID}, + chunk::{ChunkDecoder, ChunkEncoder, CHUNK_SIZE}, + handshake, + handshake::{HandshakeServer, ServerHandshakeState}, + messages::{MessageParser, RtmpMessageData}, + netconnection::NetConnection, + netstream::NetStreamWriter, + protocol_control_messages::ProtocolControlMessagesWriter, + user_control_messages::EventMessagesWriter, + PublishProducer, +}; +use amf0::Amf0Value; +use bytes::Bytes; +use bytesio::{ + bytes_writer::BytesWriter, + bytesio::{AsyncReadWrite, BytesIO}, + bytesio_errors::BytesIOError, +}; +use std::{collections::HashMap, time::Duration}; +use tokio::sync::oneshot; + +pub struct Session { + /// When you connect via rtmp, you specify the app name in the url + /// For example: rtmp://localhost:1935/live/xyz + /// The app name is "live" + /// The next part of the url is the stream name (or the stream key) "xyz" + /// However the stream key is not required to be the same for each stream you publish / play + /// Traditionally we only publish a single stream per RTMP connection, + /// However we can publish multiple streams per RTMP connection (using different stream keys) + /// and or play multiple streams per RTMP connection (using different stream keys) as per the RTMP spec. + app_name: Option, + + /// This is a unique id for this session + /// This is issued when the client connects to the server + uid: Option, + + /// Used to read and write data + io: BytesIO, + + /// Sometimes when doing the handshake we read too much data, + /// this flag is used to indicate that we have data ready to parse and we should not read more data from the stream + skip_read: bool, + + /// This is used to read the data from the stream and convert it into rtmp messages + chunk_decoder: ChunkDecoder, + /// This is used to convert rtmp messages into chunks + chunk_encoder: ChunkEncoder, + + /// StreamID + stream_id: u32, + + /// Data Producer + data_producer: DataProducer, + + /// Is Publishing + is_publishing: bool, + + /// when the publisher connects and tries to publish a stream, we need to send a publish request to the server + publish_request_producer: PublishProducer, +} + +impl Session { + pub fn new( + stream: S, + data_producer: DataProducer, + publish_request_producer: PublishProducer, + ) -> Self { + let io = BytesIO::new(stream); + + Self { + uid: None, + app_name: None, + io, + skip_read: false, + chunk_decoder: ChunkDecoder::default(), + chunk_encoder: ChunkEncoder::default(), + data_producer, + stream_id: 0, + is_publishing: false, + publish_request_producer, + } + } + + pub fn uid(&self) -> Option { + self.uid + } + + /// Run the session to completion + /// The result of the return value will be true if all publishers have disconnected + /// If any publishers are still connected, the result will be false + /// This can be used to detect non-graceful disconnects (ie. the client crashed) + pub async fn run(&mut self) -> Result { + let mut handshaker = HandshakeServer::default(); + // Run the handshake to completion + while !self.do_handshake(&mut handshaker).await? {} + + // Drop the handshaker, we don't need it anymore + // We can get rid of the memory that was allocated for it + drop(handshaker); + + tracing::debug!("Handshake complete"); + + // Run the session to completion + while match self.do_ready().await { + Ok(v) => v, + Err(SessionError::BytesIO(BytesIOError::ClientClosed)) => { + // The client closed the connection + // We are done with the session + tracing::debug!("Client closed the connection"); + false + } + Err(e) => { + return Err(e); + } + } {} + + // We should technically check the stream_map here + // However most clients just disconnect without cleanly stopping the subscrition streams (play streams) + // So we just check that all publishers have disconnected cleanly + Ok(!self.is_publishing) + } + + /// This is the first stage of the session + /// It is used to do the handshake with the client + /// The handshake is the first thing that happens when you connect to an rtmp server + async fn do_handshake( + &mut self, + handshaker: &mut HandshakeServer, + ) -> Result { + let mut bytes_len = 0; + + while bytes_len < handshake::RTMP_HANDSHAKE_SIZE { + let buf = self.io.read_timeout(Duration::from_millis(2500)).await?; + bytes_len += buf.len(); + handshaker.extend_data(&buf[..]); + } + + let mut writer = BytesWriter::default(); + handshaker.handshake(&mut writer)?; + self.write_data(writer.dispose()).await?; + + if handshaker.state() == ServerHandshakeState::Finish { + let over_read = handshaker.extract_remaining_bytes(); + + if !over_read.is_empty() { + self.skip_read = true; + self.chunk_decoder.extend_data(&over_read[..]); + } + + self.send_set_chunk_size().await?; + + // We are done with the handshake + // This causes the loop to exit + // And move onto the next stage of the session + Ok(true) + } else { + // We are not done with the handshake yet + // We need to read more data from the stream + // This causes the loop to continue + Ok(false) + } + } + + /// This is the second stage of the session + /// It is used to read data from the stream and parse it into rtmp messages + /// We also send data to the client if they are playing a stream + async fn do_ready(&mut self) -> Result { + // If we have data ready to parse, parse it + if self.skip_read { + self.skip_read = false; + } else { + let data = self.io.read_timeout(Duration::from_millis(2500)).await?; + self.chunk_decoder.extend_data(&data[..]); + } + + self.parse_chunks().await?; + + Ok(true) + } + + /// Parse data from the client into rtmp messages and process them + async fn parse_chunks(&mut self) -> Result<(), SessionError> { + while let Some(chunk) = self.chunk_decoder.read_chunk()? { + let timestamp = chunk.message_header.timestamp; + let msg_stream_id = chunk.message_header.msg_stream_id; + + if let Some(msg) = MessageParser::parse(chunk)? { + self.process_messages(msg, msg_stream_id, timestamp).await?; + } + } + + Ok(()) + } + + /// Process rtmp messages + async fn process_messages( + &mut self, + rtmp_msg: RtmpMessageData, + stream_id: u32, + timestamp: u32, + ) -> Result<(), SessionError> { + match rtmp_msg { + RtmpMessageData::Amf0Command { + command_name, + transaction_id, + command_object, + others, + } => { + self.on_amf0_command_message( + stream_id, + command_name, + transaction_id, + command_object, + others, + ) + .await? + } + RtmpMessageData::SetChunkSize { chunk_size } => { + self.on_set_chunk_size(chunk_size as usize)?; + } + RtmpMessageData::AudioData { data } => { + self.on_data(stream_id, ChannelData::Audio { timestamp, data }) + .await?; + } + RtmpMessageData::VideoData { data } => { + self.on_data(stream_id, ChannelData::Video { timestamp, data }) + .await?; + } + RtmpMessageData::AmfData { data } => { + self.on_data(stream_id, ChannelData::MetaData { timestamp, data }) + .await?; + } + } + + Ok(()) + } + + /// Set the server chunk size to the client + async fn send_set_chunk_size(&mut self) -> Result<(), SessionError> { + let mut writer = BytesWriter::default(); + ProtocolControlMessagesWriter::write_set_chunk_size( + &self.chunk_encoder, + &mut writer, + CHUNK_SIZE as u32, + )?; + self.chunk_encoder.set_chunk_size(CHUNK_SIZE); + self.write_data(writer.dispose()).await?; + + Ok(()) + } + + /// on_data is called when we receive a data message from the client (a published_stream) + /// Such as audio, video, or metadata + /// We then forward the data to the specified publisher + async fn on_data(&self, stream_id: u32, data: ChannelData) -> Result<(), SessionError> { + if stream_id != self.stream_id || !self.is_publishing { + return Err(SessionError::UnknownStreamID(stream_id)); + }; + + if self.data_producer.send(data).await.is_err() { + return Err(SessionError::PublisherDropped); + } + + Ok(()) + } + + /// on_amf0_command_message is called when we receive an AMF0 command message from the client + /// We then handle the command message + async fn on_amf0_command_message( + &mut self, + stream_id: u32, + command_name: Amf0Value, + transaction_id: Amf0Value, + command_object: Amf0Value, + others: Vec, + ) -> Result<(), SessionError> { + let cmd = RtmpCommand::from(match command_name { + Amf0Value::String(ref s) => s, + _ => "", + }); + + let transaction_id = match transaction_id { + Amf0Value::Number(number) => number, + _ => 0.0, + }; + + let obj = match command_object { + Amf0Value::Object(obj) => obj, + _ => HashMap::new(), + }; + + match cmd { + RtmpCommand::Connect => { + self.on_command_connect(transaction_id, stream_id, obj, others) + .await?; + } + RtmpCommand::CreateStream => { + self.on_command_create_stream(transaction_id, stream_id, obj, others) + .await?; + } + RtmpCommand::DeleteStream => { + self.on_command_delete_stream(transaction_id, stream_id, obj, others) + .await?; + } + RtmpCommand::Play => { + return Err(SessionError::PlayNotSupported); + } + RtmpCommand::Publish => { + self.on_command_publish(transaction_id, stream_id, obj, others) + .await?; + } + RtmpCommand::CloseStream | RtmpCommand::ReleaseStream => { + // Not sure what this is for + } + RtmpCommand::Unknown(_) => {} + } + + Ok(()) + } + + /// on_set_chunk_size is called when we receive a set chunk size message from the client + /// We then update the chunk size of the unpacketizer + fn on_set_chunk_size(&mut self, chunk_size: usize) -> Result<(), SessionError> { + if self.chunk_decoder.update_max_chunk_size(chunk_size) { + Ok(()) + } else { + Err(SessionError::InvalidChunkSize(chunk_size)) + } + } + + /// on_command_connect is called when we receive a amf0 command message with the name "connect" + /// We then handle the connect message + /// This is called when the client first connects to the server + async fn on_command_connect( + &mut self, + transaction_id: f64, + _stream_id: u32, + command_obj: HashMap, + _others: Vec, + ) -> Result<(), SessionError> { + let mut writer = BytesWriter::default(); + + ProtocolControlMessagesWriter::write_window_acknowledgement_size( + &self.chunk_encoder, + &mut writer, + CHUNK_SIZE as u32, + )?; + + ProtocolControlMessagesWriter::write_set_peer_bandwidth( + &self.chunk_encoder, + &mut writer, + CHUNK_SIZE as u32, + 2, // 2 = dynamic + )?; + + let app_name = command_obj.get("app"); + let app_name = match app_name { + Some(Amf0Value::String(app)) => app, + _ => { + return Err(SessionError::NoAppName); + } + }; + + self.app_name = Some(app_name.to_owned()); + + // The only AMF encoding supported by this server is AMF0 + // So we ignore the objectEncoding value sent by the client + // and always use AMF0 + // - OBS does not support AMF3 (https://github.com/obsproject/obs-studio/blob/1be1f51635ac85b3ad768a88b3265b192bd0bf18/plugins/obs-outputs/librtmp/rtmp.c#L1737) + // - Ffmpeg does not support AMF3 either (https://github.com/FFmpeg/FFmpeg/blob/c125860892e931d9b10f88ace73c91484815c3a8/libavformat/rtmpproto.c#L569) + // - NginxRTMP does not support AMF3 (https://github.com/arut/nginx-rtmp-module/issues/313) + // - SRS does not support AMF3 (https://github.com/ossrs/srs/blob/dcd02fe69cdbd7f401a7b8d139d95b522deb55b1/trunk/src/protocol/srs_protocol_rtmp_stack.cpp#L599) + // However, the new enhanced-rtmp-v1 spec from YouTube does encourage the use of AMF3 over AMF0 (https://github.com/veovera/enhanced-rtmp) + // We will eventually support this spec but for now we will stick to AMF0 + NetConnection::write_connect_response( + &self.chunk_encoder, + &mut writer, + transaction_id, + "FMS/3,0,1,123", // flash version (this value is used by other media servers as well) + 31.0, // No idea what this is, but it is used by other media servers as well + "NetConnection.Connect.Success", + "status", // Again not sure what this is but other media servers use it. + "Connection Succeeded.", + 0.0, + )?; + + self.write_data(writer.dispose()).await?; + + Ok(()) + } + + /// on_command_create_stream is called when we receive a amf0 command message with the name "createStream" + /// We then handle the createStream message + /// This is called when the client wants to create a stream + /// A NetStream is used to start publishing or playing a stream + async fn on_command_create_stream( + &mut self, + transaction_id: f64, + _stream_id: u32, + _command_obj: HashMap, + _others: Vec, + ) -> Result<(), SessionError> { + let mut writer = BytesWriter::default(); + // 1.0 is the Stream ID of the stream we are creating + NetConnection::write_create_stream_response( + &self.chunk_encoder, + &mut writer, + transaction_id, + 1.0, + )?; + self.write_data(writer.dispose()).await?; + + Ok(()) + } + + /// A delete stream message is unrelated to the NetConnection close method. + /// Delete stream is basically a way to tell the server that you are done publishing or playing a stream. + /// The server will then remove the stream from its list of streams. + async fn on_command_delete_stream( + &mut self, + transaction_id: f64, + _stream_id: u32, + _command_obj: HashMap, + others: Vec, + ) -> Result<(), SessionError> { + let mut writer = BytesWriter::default(); + + let stream_id = match others.get(0) { + Some(Amf0Value::Number(stream_id)) => *stream_id, + _ => 0.0, + } as u32; + + if self.stream_id == stream_id && self.is_publishing { + self.stream_id = 0; + self.is_publishing = false; + } + + NetStreamWriter::write_on_status( + &self.chunk_encoder, + &mut writer, + transaction_id, + "status", + "NetStream.DeleteStream.Suceess", + "", + )?; + + self.write_data(writer.dispose()).await?; + + Ok(()) + } + + /// on_command_publish is called when we receive a amf0 command message with the name "publish" + /// publish commands are used to publish a stream to the server + /// ie. the user wants to start streaming to the server + async fn on_command_publish( + &mut self, + transaction_id: f64, + stream_id: u32, + _command_obj: HashMap, + others: Vec, + ) -> Result<(), SessionError> { + let stream_name = match others.get(0) { + Some(Amf0Value::String(val)) => val, + _ => { + return Err(SessionError::NoStreamName); + } + }; + + let Some(app_name) = &self.app_name else { + return Err(SessionError::NoAppName); + }; + + let (response, waiter) = oneshot::channel(); + + if self + .publish_request_producer + .send(PublishRequest { + app_name: app_name.clone(), + stream_name: stream_name.clone(), + response, + }) + .await + .is_err() + { + return Err(SessionError::PublishRequestDenied); + } + + let Ok(uid) = waiter.await else { + return Err(SessionError::PublishRequestDenied); + }; + + self.uid = Some(uid); + + self.is_publishing = true; + self.stream_id = stream_id; + + let mut writer = BytesWriter::default(); + EventMessagesWriter::write_stream_begin(&self.chunk_encoder, &mut writer, stream_id)?; + + NetStreamWriter::write_on_status( + &self.chunk_encoder, + &mut writer, + transaction_id, + "status", + "NetStream.Publish.Start", + "", + )?; + + self.write_data(writer.dispose()).await?; + + Ok(()) + } + + /// write_data is a helper function to write data to the underlying connection. + /// If the data is empty, it will not write anything. + /// This is to avoid writing empty bytes to the underlying connection. + async fn write_data(&mut self, data: Bytes) -> Result<(), SessionError> { + if !data.is_empty() { + self.io.write_timeout(data, Duration::from_secs(2)).await?; + } + + Ok(()) + } +} diff --git a/video/protocol/rtmp/src/session/tests.rs b/video/protocol/rtmp/src/session/tests.rs new file mode 100644 index 00000000..a40db516 --- /dev/null +++ b/video/protocol/rtmp/src/session/tests.rs @@ -0,0 +1,98 @@ +use bytesio::bytesio_errors::BytesIOError; + +use crate::{ + chunk::{ChunkDecodeError, ChunkEncodeError}, + handshake::{DigestError, HandshakeError}, + messages::MessageError, + netconnection::NetConnectionError, + netstream::NetStreamError, + protocol_control_messages::ProtocolControlMessageError, + user_control_messages::EventMessagesError, + SessionError, UniqueID, +}; + +#[test] +fn test_error_display() { + let error = SessionError::BytesIO(BytesIOError::ClientClosed); + assert_eq!(error.to_string(), "bytesio error: client closed"); + + let error = SessionError::Handshake(HandshakeError::Digest(DigestError::NotEnoughData)); + assert_eq!( + error.to_string(), + "handshake error: digest error: not enough data" + ); + + let error = SessionError::Message(MessageError::Amf0Read(amf0::Amf0ReadError::WrongType)); + assert_eq!( + error.to_string(), + "message error: amf0 read error: wrong type" + ); + + let error = SessionError::ChunkDecode(ChunkDecodeError::TooManyPreviousChunkHeaders); + assert_eq!( + error.to_string(), + "chunk decode error: too many previous chunk headers" + ); + + let error = SessionError::ProtocolControlMessage(ProtocolControlMessageError::ChunkEncode( + ChunkEncodeError::UnknownReadState, + )); + assert_eq!( + error.to_string(), + "protocol control message error: chunk encode error: unknown read state" + ); + + let error = SessionError::NetStream(NetStreamError::ChunkEncode( + ChunkEncodeError::UnknownReadState, + )); + assert_eq!( + error.to_string(), + "netstream error: chunk encode error: unknown read state" + ); + + let error = SessionError::NetConnection(NetConnectionError::ChunkEncode( + ChunkEncodeError::UnknownReadState, + )); + assert_eq!( + error.to_string(), + "netconnection error: chunk encode error: unknown read state" + ); + + let error = SessionError::EventMessages(EventMessagesError::ChunkEncode( + ChunkEncodeError::UnknownReadState, + )); + assert_eq!( + error.to_string(), + "event messages error: chunk encode error: unknown read state" + ); + + let error = SessionError::UnknownStreamID(0); + assert_eq!(error.to_string(), "unknown stream id: 0"); + + let error = SessionError::PublisherDisconnected(UniqueID::nil()); + assert_eq!( + error.to_string(), + "publisher disconnected: 00000000-0000-0000-0000-000000000000" + ); + + let error = SessionError::NoAppName; + assert_eq!(error.to_string(), "no app name"); + + let error = SessionError::NoStreamName; + assert_eq!(error.to_string(), "no stream name"); + + let error = SessionError::PublishRequestDenied; + assert_eq!(error.to_string(), "publish request denied"); + + let error = SessionError::ConnectRequestDenied; + assert_eq!(error.to_string(), "connect request denied"); + + let error = SessionError::PlayNotSupported; + assert_eq!(error.to_string(), "play not supported"); + + let error = SessionError::PublisherDropped; + assert_eq!(error.to_string(), "publisher dropped"); + + let error = SessionError::InvalidChunkSize(123); + assert_eq!(error.to_string(), "invalid chunk size: 123"); +} diff --git a/video/protocol/rtmp/src/tests/mod.rs b/video/protocol/rtmp/src/tests/mod.rs new file mode 100644 index 00000000..b07a959d --- /dev/null +++ b/video/protocol/rtmp/src/tests/mod.rs @@ -0,0 +1 @@ +mod rtmp; diff --git a/video/protocol/rtmp/src/tests/rtmp.rs b/video/protocol/rtmp/src/tests/rtmp.rs new file mode 100644 index 00000000..14c7bc72 --- /dev/null +++ b/video/protocol/rtmp/src/tests/rtmp.rs @@ -0,0 +1,214 @@ +use std::{path::PathBuf, time::Duration}; + +use common::prelude::FutureTimeout; +use tokio::{process::Command, sync::mpsc}; + +use crate::{ + channels::{ChannelData, UniqueID}, + Session, +}; + +#[tokio::test] +async fn test_basic_rtmp_clean() { + let listener = tokio::net::TcpListener::bind("0.0.0.0:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let mut ffmpeg = Command::new("ffmpeg") + .args([ + "-re", + "-i", + dir.join("avc_aac.mp4") + .to_str() + .expect("failed to get path"), + "-r", + "30", + "-t", + "1", // just for the test so it doesn't take too long + "-c", + "copy", + "-f", + "flv", + &format!("rtmp://{}:{}/live/stream-key", addr.ip(), addr.port()), + ]) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("failed to execute ffmpeg"); + + let (ffmpeg_stream, _) = listener + .accept() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + .expect("failed to accept"); + + let (ffmpeg_handle, mut ffmpeg_data_reciever, mut ffmpeg_event_reciever) = { + let (ffmpeg_event_producer, ffmpeg_event_reciever) = mpsc::channel(1); + let (ffmpeg_data_producer, ffmpeg_data_reciever) = mpsc::channel(128); + let mut session = Session::new(ffmpeg_stream, ffmpeg_data_producer, ffmpeg_event_producer); + + ( + tokio::spawn(async move { + let r = session.run().await; + tracing::debug!("ffmpeg session ended: {:?}", r); + r + }), + ffmpeg_data_reciever, + ffmpeg_event_reciever, + ) + }; + + let event = ffmpeg_event_reciever + .recv() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + .expect("failed to recv event"); + + assert_eq!(event.app_name, "live"); + assert_eq!(event.stream_name, "stream-key"); + + let stream_id = UniqueID::new_v4(); + event + .response + .send(stream_id) + .expect("failed to send response"); + + let mut got_video = false; + let mut got_audio = false; + let mut got_metadata = false; + + while let Some(data) = ffmpeg_data_reciever + .recv() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + { + match data { + ChannelData::Video { .. } => got_video = true, + ChannelData::Audio { .. } => got_audio = true, + ChannelData::MetaData { .. } => got_metadata = true, + } + } + + assert!(got_video); + assert!(got_audio); + assert!(got_metadata); + + assert!(ffmpeg_handle + .await + .expect("failed to join handle") + .expect("failed to handle ffmpeg connection")); + assert!(ffmpeg + .try_wait() + .expect("failed to wait for ffmpeg") + .is_none()); +} + +#[tokio::test] +async fn test_basic_rtmp_unclean() { + let listener = tokio::net::TcpListener::bind("0.0.0.0:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets"); + + let mut ffmpeg = Command::new("ffmpeg") + .args([ + "-re", + "-i", + dir.join("avc_aac.mp4") + .to_str() + .expect("failed to get path"), + "-r", + "30", + "-t", + "1", // just for the test so it doesn't take too long + "-c", + "copy", + "-f", + "flv", + &format!("rtmp://{}:{}/live/stream-key", addr.ip(), addr.port()), + ]) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("failed to execute ffmpeg"); + + let (ffmpeg_stream, _) = listener + .accept() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + .expect("failed to accept"); + + let (ffmpeg_handle, mut ffmpeg_data_reciever, mut ffmpeg_event_reciever) = { + let (ffmpeg_event_producer, ffmpeg_event_reciever) = mpsc::channel(1); + let (ffmpeg_data_producer, ffmpeg_data_reciever) = mpsc::channel(128); + let mut session = Session::new(ffmpeg_stream, ffmpeg_data_producer, ffmpeg_event_producer); + + ( + tokio::spawn(async move { + let r = session.run().await; + tracing::debug!("ffmpeg session ended: {:?}", r); + r + }), + ffmpeg_data_reciever, + ffmpeg_event_reciever, + ) + }; + + let event = ffmpeg_event_reciever + .recv() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + .expect("failed to recv event"); + + assert_eq!(event.app_name, "live"); + assert_eq!(event.stream_name, "stream-key"); + + let stream_id = UniqueID::new_v4(); + event + .response + .send(stream_id) + .expect("failed to send response"); + + let mut got_video = false; + let mut got_audio = false; + let mut got_metadata = false; + + while let Some(data) = ffmpeg_data_reciever + .recv() + .timeout(Duration::from_millis(1000)) + .await + .expect("timedout") + { + match data { + ChannelData::Video { .. } => got_video = true, + ChannelData::Audio { .. } => got_audio = true, + ChannelData::MetaData { .. } => got_metadata = true, + } + + if got_video && got_audio && got_metadata { + break; + } + } + + assert!(got_video); + assert!(got_audio); + assert!(got_metadata); + + ffmpeg.kill().await.expect("failed to kill ffmpeg"); + + // the server should have detected the ffmpeg process has died uncleanly + assert!(!ffmpeg_handle + .await + .expect("failed to join handle") + .expect("failed to handle ffmpeg connection")); +} diff --git a/video/protocol/rtmp/src/user_control_messages/define.rs b/video/protocol/rtmp/src/user_control_messages/define.rs new file mode 100644 index 00000000..5fee5db2 --- /dev/null +++ b/video/protocol/rtmp/src/user_control_messages/define.rs @@ -0,0 +1 @@ +pub const RTMP_EVENT_STREAM_BEGIN: u16 = 0; diff --git a/video/protocol/rtmp/src/user_control_messages/errors.rs b/video/protocol/rtmp/src/user_control_messages/errors.rs new file mode 100644 index 00000000..a1c4fe43 --- /dev/null +++ b/video/protocol/rtmp/src/user_control_messages/errors.rs @@ -0,0 +1,19 @@ +use crate::{chunk::ChunkEncodeError, macros::from_error}; +use std::fmt; + +#[derive(Debug)] +pub enum EventMessagesError { + ChunkEncode(ChunkEncodeError), +} + +from_error!(EventMessagesError, Self::ChunkEncode, ChunkEncodeError); + +impl fmt::Display for EventMessagesError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self { + Self::ChunkEncode(e) => { + write!(f, "chunk encode error: {}", e) + } + } + } +} diff --git a/video/protocol/rtmp/src/user_control_messages/mod.rs b/video/protocol/rtmp/src/user_control_messages/mod.rs new file mode 100644 index 00000000..22e3244b --- /dev/null +++ b/video/protocol/rtmp/src/user_control_messages/mod.rs @@ -0,0 +1,8 @@ +mod define; +mod errors; +mod writer; + +pub use self::{errors::EventMessagesError, writer::EventMessagesWriter}; + +#[cfg(test)] +mod tests; diff --git a/video/protocol/rtmp/src/user_control_messages/tests.rs b/video/protocol/rtmp/src/user_control_messages/tests.rs new file mode 100644 index 00000000..32609e66 --- /dev/null +++ b/video/protocol/rtmp/src/user_control_messages/tests.rs @@ -0,0 +1,36 @@ +use bytes::Bytes; +use bytesio::bytes_writer::BytesWriter; + +use crate::{ + chunk::{ChunkDecoder, ChunkEncodeError, ChunkEncoder}, + user_control_messages::{EventMessagesError, EventMessagesWriter}, +}; + +#[test] +fn test_error_display() { + let error = EventMessagesError::ChunkEncode(ChunkEncodeError::UnknownReadState); + assert_eq!( + format!("{}", error), + "chunk encode error: unknown read state" + ); +} + +#[test] +fn test_write_stream_begin() { + let mut writer = BytesWriter::default(); + let encoder = ChunkEncoder::default(); + + EventMessagesWriter::write_stream_begin(&encoder, &mut writer, 1).unwrap(); + + let mut decoder = ChunkDecoder::default(); + decoder.extend_data(&writer.dispose()); + + let chunk = decoder.read_chunk().unwrap().unwrap(); + assert_eq!(chunk.basic_header.chunk_stream_id, 0x02); + assert_eq!(chunk.message_header.msg_type_id as u8, 0x04); + assert_eq!(chunk.message_header.msg_stream_id, 0); + assert_eq!( + chunk.payload, + Bytes::from(vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x01]) + ); +} diff --git a/video/protocol/rtmp/src/user_control_messages/writer.rs b/video/protocol/rtmp/src/user_control_messages/writer.rs new file mode 100644 index 00000000..f1a78242 --- /dev/null +++ b/video/protocol/rtmp/src/user_control_messages/writer.rs @@ -0,0 +1,31 @@ +use crate::{ + chunk::{Chunk, ChunkEncoder}, + messages::MessageTypeID, +}; + +use super::{define, errors::EventMessagesError}; +use byteorder::{BigEndian, WriteBytesExt}; +use bytesio::bytes_writer::BytesWriter; + +pub struct EventMessagesWriter; + +impl EventMessagesWriter { + pub fn write_stream_begin( + encoder: &ChunkEncoder, + writer: &mut BytesWriter, + stream_id: u32, + ) -> Result<(), EventMessagesError> { + let mut data = Vec::new(); + + data.write_u16::(define::RTMP_EVENT_STREAM_BEGIN) + .expect("write u16"); + data.write_u32::(stream_id).expect("write u32"); + + encoder.write_chunk( + writer, + Chunk::new(0x02, 0, MessageTypeID::UserControlEvent, 0, data.into()), + )?; + + Ok(()) + } +} diff --git a/video/transcoder/Cargo.toml b/video/transcoder/Cargo.toml index 720eb2b0..ce4744b9 100644 --- a/video/transcoder/Cargo.toml +++ b/video/transcoder/Cargo.toml @@ -6,9 +6,39 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.68" -tracing = "0.1.37" -tokio = { version = "1.25.0", features = ["full"] } -serde = { version = "1.0.152", features = ["derive"] } -hyper = { version = "0.14.24", features = ["full"] } +anyhow = "1" +tracing = "0" +native-tls = "0" +tokio-native-tls = "0" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +hyper = { version = "0", features = ["full"] } +tonic = { version = "0", features = ["tls"] } +chrono = { version = "0", default-features = false, features = ["clock"] } +prost = "0" +async-stream = "0" +futures = "0" +futures-util = "0" +bytes = "1" +async-trait = "0" +fred = { version = "6", features = ["enable-native-tls", "sentinel-client", "sentinel-auth", "subscriber-client"] } +url-parse = "1" +nix = "0" +sha2 = "0" +tokio-util = "0" +tokio-stream = "0" +lapin = { version = "2.0.3", features = ["native-tls"] } + +mp4 = { path = "../container/mp4" } common = { path = "../../common" } +bytesio = { path = "../bytesio" } + +[build-dependencies] +tonic-build = "0" +prost-build = "0" + +[dev-dependencies] +dotenvy = "0" +portpicker = "0" +serial_test = "2" +tempfile = "3" diff --git a/video/transcoder/build.rs b/video/transcoder/build.rs new file mode 100644 index 00000000..3c3ceedf --- /dev/null +++ b/video/transcoder/build.rs @@ -0,0 +1,23 @@ +const PROTO_DIR: &str = "../../proto"; + +fn main() { + let mut config = prost_build::Config::new(); + + config.protoc_arg("--experimental_allow_proto3_optional"); + config.bytes(["."]); + + tonic_build::configure() + .compile_with_config( + config, + &[ + format!("{}/scuffle/events/ingest.proto", PROTO_DIR), + format!("{}/scuffle/events/transcoder.proto", PROTO_DIR), + format!("{}/scuffle/backend/api.proto", PROTO_DIR), + format!("{}/scuffle/video/ingest.proto", PROTO_DIR), + format!("{}/scuffle/video/transcoder.proto", PROTO_DIR), + format!("{}/scuffle/utils/health.proto", PROTO_DIR), + ], + &[PROTO_DIR], + ) + .unwrap(); +} diff --git a/video/transcoder/src/config.rs b/video/transcoder/src/config.rs index 7aff744e..7903f988 100644 --- a/video/transcoder/src/config.rs +++ b/video/transcoder/src/config.rs @@ -1,21 +1,219 @@ +use std::net::SocketAddr; + use anyhow::Result; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(default)] -pub struct AppConfig { +pub struct TlsConfig { + /// Domain name to use for TLS + /// Only used for gRPC TLS connections + pub domain: Option, + + /// The path to the TLS certificate + pub cert: String, + + /// The path to the TLS private key + pub key: String, + + /// The path to the TLS CA certificate + pub ca_cert: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct ApiConfig { + /// The bind address for the API server + pub addresses: Vec, + + /// Resolve interval in seconds (0 to disable) + pub resolve_interval: u64, + + /// If we should use TLS for the API server + pub tls: Option, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + addresses: vec!["localhost:50051".to_string()], + resolve_interval: 30, + tls: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct GrpcConfig { + /// The bind address for the gRPC server + pub bind_address: SocketAddr, + + /// If we should use TLS for the gRPC server + pub tls: Option, +} + +impl Default for GrpcConfig { + fn default() -> Self { + Self { + bind_address: "[::]:50053".to_string().parse().unwrap(), + tls: None, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct IngestConfig { + /// If we should use TLS for the API server + pub tls: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RmqConfig { + /// URI for RMQ + pub uri: String, + + /// Stream name used for transcoder requests + pub transcoder_queue: String, +} + +impl Default for RmqConfig { + fn default() -> Self { + Self { + uri: "amqp://rabbitmq:rabbitmq@localhost:5672/scuffle".to_string(), + transcoder_queue: "transcoder".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct RedisConfig { + /// The address of the Redis server + pub addresses: Vec, + + /// Number of connections to keep in the pool + pub pool_size: usize, + + /// The username to use for authentication + pub username: Option, + + /// The password to use for authentication + pub password: Option, + + /// The database to use + pub database: u8, + + /// The TLS configuration + pub tls: Option, + + /// To use Redis Sentinel + pub sentinel: Option, +} + +impl Default for RedisConfig { + fn default() -> Self { + Self { + addresses: vec!["localhost:6379".to_string()], + pool_size: 10, + username: None, + password: None, + database: 0, + tls: None, + sentinel: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct RedisSentinelConfig { + /// The master group name + pub service_name: String, +} + +impl Default for RedisSentinelConfig { + fn default() -> Self { + Self { + service_name: "myservice".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct LoggingConfig { /// The log level to use, this is a tracing env filter - pub log_level: String, + pub level: String, + + /// If we should use JSON logging + pub json: bool, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: "info".to_string(), + json: false, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct TranscoderConfig { + /// The direcory to create unix sockets in + pub socket_dir: String, +} + +impl Default for TranscoderConfig { + fn default() -> Self { + Self { + socket_dir: "/tmp".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct AppConfig { + /// Name of this instance + pub name: String, /// The path to the config file. pub config_file: String, + + /// The log level to use, this is a tracing env filter + pub logging: LoggingConfig, + + /// API client configuration + pub api: ApiConfig, + + /// gRPC server configuration + pub grpc: GrpcConfig, + + /// RMQ configuration + pub rmq: RmqConfig, + + /// Redis configuration + pub redis: RedisConfig, + + /// Transcoder configuration + pub transcoder: TranscoderConfig, } impl Default for AppConfig { fn default() -> Self { Self { - log_level: "transcode=info".to_string(), + name: "scuffle-transcoder".to_string(), config_file: "config".to_string(), + api: ApiConfig::default(), + grpc: GrpcConfig::default(), + logging: LoggingConfig::default(), + rmq: RmqConfig::default(), + redis: RedisConfig::default(), + transcoder: TranscoderConfig::default(), } } } diff --git a/video/transcoder/src/global.rs b/video/transcoder/src/global.rs new file mode 100644 index 00000000..e1a967ed --- /dev/null +++ b/video/transcoder/src/global.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use common::context::Context; +use fred::{ + pool::RedisPool, + types::{PerformanceConfig, ReconnectPolicy, RedisConfig, ServerConfig}, +}; +use lapin::{ + options::QueueDeclareOptions, + types::{AMQPValue, FieldTable}, +}; + +use crate::config::AppConfig; + +pub struct GlobalState { + pub config: AppConfig, + pub ctx: Context, + pub rmq: common::rmq::ConnectionPool, + pub redis: RedisPool, +} + +impl GlobalState { + pub fn new( + config: AppConfig, + ctx: Context, + rmq: common::rmq::ConnectionPool, + redis: RedisPool, + ) -> Self { + Self { + config, + ctx, + rmq, + redis, + } + } +} + +pub async fn init_rmq(global: &Arc) { + let channel = global.rmq.aquire().await.expect("failed to create channel"); + + let mut options = FieldTable::default(); + + options.insert("x-message-ttl".into(), AMQPValue::LongUInt(60 * 1000)); + + channel + .queue_declare( + &global.config.rmq.transcoder_queue, + QueueDeclareOptions { + durable: true, + ..Default::default() + }, + options, + ) + .await + .expect("failed to declare queue"); +} + +pub fn setup_redis(config: &AppConfig) -> RedisPool { + let mut redis_config = RedisConfig::default(); + let performance = PerformanceConfig::default(); + let policy = ReconnectPolicy::default(); + + redis_config.database = Some(config.redis.database); + redis_config.username = config.redis.username.clone(); + redis_config.password = config.redis.password.clone(); + + redis_config.server = if let Some(sentinel) = &config.redis.sentinel { + let addresses = config + .redis + .addresses + .iter() + .map(|a| { + let mut parts = a.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + (host, port) + }) + .collect::>(); + + ServerConfig::new_sentinel(addresses, sentinel.service_name.clone()) + } else { + let server = config.redis.addresses.first().expect("no redis addresses"); + if config.redis.addresses.len() > 1 { + tracing::warn!("multiple redis addresses, only using first: {}", server); + } + + let mut parts = server.split(':'); + let host = parts.next().expect("no redis host"); + let port = parts + .next() + .expect("no redis port") + .parse() + .expect("failed to parse redis port"); + + ServerConfig::new_centralized(host, port) + }; + + redis_config.tls = if let Some(tls) = &config.redis.tls { + let cert = std::fs::read(&tls.cert).expect("failed to read redis cert"); + let key = std::fs::read(&tls.key).expect("failed to read redis key"); + let ca_cert = std::fs::read(&tls.ca_cert).expect("failed to read redis ca"); + + Some( + fred::native_tls::TlsConnector::builder() + .identity( + native_tls::Identity::from_pkcs8(&cert, &key) + .expect("failed to parse redis cert/key"), + ) + .add_root_certificate( + native_tls::Certificate::from_pem(&ca_cert).expect("failed to parse redis ca"), + ) + .build() + .expect("failed to build redis tls") + .into(), + ) + } else { + None + }; + + RedisPool::new( + redis_config, + Some(performance), + Some(policy), + config.redis.pool_size, + ) + .expect("failed to create redis pool") +} diff --git a/video/transcoder/src/grpc/health.rs b/video/transcoder/src/grpc/health.rs new file mode 100644 index 00000000..8f63d9ff --- /dev/null +++ b/video/transcoder/src/grpc/health.rs @@ -0,0 +1,66 @@ +use crate::global::GlobalState; +use std::{ + pin::Pin, + sync::{Arc, Weak}, +}; + +use async_stream::try_stream; +use futures_util::Stream; +use tonic::{async_trait, Request, Response, Status}; + +use crate::pb::health::{ + health_check_response::ServingStatus, health_server, HealthCheckRequest, HealthCheckResponse, +}; + +pub struct HealthServer { + global: Weak, +} + +impl HealthServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl health_server::Health for HealthServer { + type WatchStream = Pin> + Send>>; + + async fn check(&self, _: Request) -> Result> { + let serving = self + .global + .upgrade() + .map(|g| !g.ctx.is_done()) + .unwrap_or_default(); + + Ok(Response::new(HealthCheckResponse { + status: if serving { + ServingStatus::Serving.into() + } else { + ServingStatus::NotServing.into() + }, + })) + } + + async fn watch(&self, _: Request) -> Result> { + let global = self.global.clone(); + + let output = try_stream! { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let serving = global.upgrade().map(|g| !g.ctx.is_done()).unwrap_or_default(); + + yield HealthCheckResponse { + status: if serving { ServingStatus::Serving.into() } else { ServingStatus::NotServing.into() }, + }; + } + }; + + Ok(Response::new(Box::pin(output))) + } +} diff --git a/video/transcoder/src/grpc/mod.rs b/video/transcoder/src/grpc/mod.rs new file mode 100644 index 00000000..9c066843 --- /dev/null +++ b/video/transcoder/src/grpc/mod.rs @@ -0,0 +1,53 @@ +use crate::{ + global::GlobalState, + pb::{health::health_server, scuffle::video::transcoder_server}, +}; +use anyhow::Result; +use std::sync::Arc; +use tokio::select; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; + +pub mod health; +pub mod transcoder; + +pub async fn run(global: Arc) -> Result<()> { + tracing::info!("gRPC Listening on {}", global.config.grpc.bind_address); + + let server = if let Some(tls) = &global.config.grpc.tls { + let cert = tokio::fs::read(&tls.cert).await?; + let key = tokio::fs::read(&tls.key).await?; + let ca_cert = tokio::fs::read(&tls.ca_cert).await?; + tracing::info!("gRPC TLS enabled"); + Server::builder().tls_config( + ServerTlsConfig::new() + .identity(Identity::from_pem(cert, key)) + .client_ca_root(Certificate::from_pem(ca_cert)), + )? + } else { + tracing::info!("gRPC TLS disabled"); + Server::builder() + } + .add_service(transcoder_server::TranscoderServer::new( + transcoder::TranscoderServer::new(&global), + )) + .add_service(health_server::HealthServer::new(health::HealthServer::new( + &global, + ))) + .serve_with_shutdown(global.config.grpc.bind_address, async { + global.ctx.done().await; + }); + + select! { + _ = global.ctx.done() => { + return Ok(()); + }, + r = server => { + if let Err(r) = r { + tracing::error!("gRPC server failed: {:?}", r); + return Err(r.into()); + } + }, + } + + Ok(()) +} diff --git a/video/transcoder/src/grpc/transcoder.rs b/video/transcoder/src/grpc/transcoder.rs new file mode 100644 index 00000000..c627fb62 --- /dev/null +++ b/video/transcoder/src/grpc/transcoder.rs @@ -0,0 +1,24 @@ +#![allow(dead_code)] +// TODO: Remove this once we have a real implementation + +use crate::{global::GlobalState, pb::scuffle::video::transcoder_server}; +use std::sync::{Arc, Weak}; + +use tonic::{async_trait, Status}; + +pub struct TranscoderServer { + global: Weak, +} + +impl TranscoderServer { + pub fn new(global: &Arc) -> Self { + Self { + global: Arc::downgrade(global), + } + } +} + +type Result = std::result::Result; + +#[async_trait] +impl transcoder_server::Transcoder for TranscoderServer {} diff --git a/video/transcoder/src/main.rs b/video/transcoder/src/main.rs index 24d25306..0cc969ed 100644 --- a/video/transcoder/src/main.rs +++ b/video/transcoder/src/main.rs @@ -1,24 +1,81 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use anyhow::Result; -use common::logging; -use tokio::select; +use anyhow::{Context as _, Result}; +use common::{context::Context, logging, prelude::FutureTimeout, signal}; +use tokio::{select, signal::unix::SignalKind, time}; mod config; +mod global; +mod grpc; +mod pb; mod transcoder; #[tokio::main] async fn main() -> Result<()> { - let config = Arc::new(config::AppConfig::parse()?); + let config = config::AppConfig::parse()?; - logging::init(&config.log_level)?; + logging::init(&config.logging.level, config.logging.json)?; - tracing::info!("starting"); + tracing::info!("starting: loaded config from {}", config.config_file); + + let (ctx, handler) = Context::new(); + + let rmq = common::rmq::ConnectionPool::connect( + config.rmq.uri.clone(), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .context("failed to connect to rabbitmq, timedout")? + .context("failed to connect to rabbitmq")?; + + let redis = global::setup_redis(&config); + redis.connect(); + + redis + .wait_for_connect() + .timeout(Duration::from_secs(2)) + .await + .expect("failed to connect to redis") + .expect("failed to connect to redis"); + tracing::info!("connected to redis"); + + let global = Arc::new(global::GlobalState::new(config, ctx, rmq, redis)); + + global::init_rmq(&global).await; + tracing::info!("initialized rmq"); + + let transcoder_future = tokio::spawn(transcoder::run(global.clone())); + let grpc_future = tokio::spawn(grpc::run(global.clone())); + + // Listen on both sigint and sigterm and cancel the context when either is received + let mut signal_handler = signal::SignalHandler::new() + .with_signal(SignalKind::interrupt()) + .with_signal(SignalKind::terminate()); select! { - _ = transcoder::run(config.clone()) => tracing::info!("transcoder stopped"), - _ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received"), + r = transcoder_future => tracing::error!("transcoder stopped unexpectedly: {:?}", r), + r = grpc_future => tracing::error!("grpc stopped unexpectedly: {:?}", r), + r = global.rmq.handle_reconnects() => tracing::error!("rabbitmq stopped unexpectedly: {:?}", r), + _ = signal_handler.recv() => tracing::info!("shutting down"), + } + + // We cannot have a context in scope when we cancel the handler, otherwise it will deadlock. + drop(global); + + // Cancel the context + tracing::info!("waiting for tasks to finish"); + + select! { + _ = time::sleep(Duration::from_secs(60)) => tracing::warn!("force shutting down"), + _ = signal_handler.recv() => tracing::warn!("force shutting down"), + _ = handler.cancel() => tracing::info!("shutting down"), } Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/video/transcoder/src/pb.rs b/video/transcoder/src/pb.rs new file mode 100644 index 00000000..4f4bd310 --- /dev/null +++ b/video/transcoder/src/pb.rs @@ -0,0 +1,23 @@ +#![allow(clippy::match_single_binding)] + +pub mod scuffle { + pub mod backend { + tonic::include_proto!("scuffle.backend"); + } + + pub mod types { + tonic::include_proto!("scuffle.types"); + } + + pub mod video { + tonic::include_proto!("scuffle.video"); + } + + pub mod events { + tonic::include_proto!("scuffle.events"); + } +} + +pub mod health { + tonic::include_proto!("grpc.health.v1"); +} diff --git a/video/transcoder/src/tests/certs/ca.ec.crt b/video/transcoder/src/tests/certs/ca.ec.crt new file mode 100644 index 00000000..9436c192 --- /dev/null +++ b/video/transcoder/src/tests/certs/ca.ec.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBbDCCARGgAwIBAgITAQQHMJ/YiOeCwmwdOLS//1SnIjAKBggqhkjOPQQDAjAU +MRIwEAYDVQQDDAkxMjcuMC4wLjEwHhcNMjMwNDI2MDc0NTA2WhcNMjQwNDI1MDc0 +NTA2WjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwWTATBgcqhkjOPQIBBggqhkjOPQMB +BwNCAATQFyQcMa94peoJBphHsQaDVFUaHSKQEgp8/XmENO1U0pMsZ9Bbi/mV61VX +oSnNz3e0ZuikM/O4BbMZJOFQ2hvNo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBhjAdBgNVHQ4EFgQU99Fg74uP9OWTuE76fcbEeKcHI2swCgYIKoZI +zj0EAwIDSQAwRgIhANgP5OsuQrd0eNntAg/hFK8dxemQ2AJEdt4rP/K46N8iAiEA +hS1Sj/OF17SOIsOAq8lE1GZo47TLI7Wq2pmJ3kGSzJg= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/ca.ec.key b/video/transcoder/src/tests/certs/ca.ec.key new file mode 100644 index 00000000..1e30c6cc --- /dev/null +++ b/video/transcoder/src/tests/certs/ca.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXJgb1t4MQg7IH5+G +pb7AQz1dJf1P1cETYSVHUqHBkJWhRANCAATQFyQcMa94peoJBphHsQaDVFUaHSKQ +Egp8/XmENO1U0pMsZ9Bbi/mV61VXoSnNz3e0ZuikM/O4BbMZJOFQ2hvN +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/certs/ca.ini b/video/transcoder/src/tests/certs/ca.ini new file mode 100644 index 00000000..bf362da6 --- /dev/null +++ b/video/transcoder/src/tests/certs/ca.ini @@ -0,0 +1,13 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +# Since this is a CA, the key usage is critical +x509_extensions = v3_ca + +[v3_ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical, digitalSignature, cRLSign, keyCertSign + +[dn] +CN = 127.0.0.1 diff --git a/video/transcoder/src/tests/certs/ca.rsa.crt b/video/transcoder/src/tests/certs/ca.rsa.crt new file mode 100644 index 00000000..44754f3d --- /dev/null +++ b/video/transcoder/src/tests/certs/ca.rsa.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+DCCAeCgAwIBAgIUF9vFd3f/op4WwWKQ2tvewBobsAEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA3sYI93c8gGWSaqtDygC+fMZDhy3Wxgg7dh+45IDQsVNp +sd8BGwrB9Ge2fjZvk/8XVa437ZDKgehodN8fBn9LrypPt2s7hPG1a3RYnd3fa5/t +QSsMt9Aodb3zymsnonxig+D/qSDqXi/ZNikbQ7+PvG1UDLtl10oGdrMHyHFcxxhW +1lEya3unffkLJaL9TDLJs9E5XqRFCKBMQVygjoF9ToMGaYD+KyIrdpaVIsVS3W5j +Jj6IADgEnJ6yTYpKEgxSgFok3yWqmFRCfdN4TfiDBAiAKCbsKxCDbFOnntOjWBW+ +HYhloh7ZSRNeYm1DSArb9Kn+iJMRxxx1fa+DOJpGRwIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQULYXilygDXYi+UFjp +pyZZLl3UUv8wDQYJKoZIhvcNAQELBQADggEBACFIxzF1MRQY1uRy+aTn1731JhBP +5rlJrfIQxF4m3PlHeOJrpc09KORSsUsC048MK2abrkC+A2SZoIQKBBL3PporKlnU +aRV8lOtIJ2X2/VTQTM9I/dCqKBzEAwyPSLn75WL9tif++nSKamWgT5Fk5I664qiP +FVzFOIXzBDNzNrWKZXsZMoqJUnj0tZiQxrfWpMeUpJr/mKSDS179ruF79jFXym7+ +dURX0xrCfeh70w+oq/xg91bpwhcQX9GWNLc7OFSnwxE4aeBZGNcttFXWB7Z8EcSp +z+QyHl/SFr89ZMqGaqkxAE6py3TsAj4TKo4vJr6+6aUrVhZcabd0SC8LH5c= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/ca.rsa.key b/video/transcoder/src/tests/certs/ca.rsa.key new file mode 100644 index 00000000..59be34b8 --- /dev/null +++ b/video/transcoder/src/tests/certs/ca.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDexgj3dzyAZZJq +q0PKAL58xkOHLdbGCDt2H7jkgNCxU2mx3wEbCsH0Z7Z+Nm+T/xdVrjftkMqB6Gh0 +3x8Gf0uvKk+3azuE8bVrdFid3d9rn+1BKwy30Ch1vfPKayeifGKD4P+pIOpeL9k2 +KRtDv4+8bVQMu2XXSgZ2swfIcVzHGFbWUTJre6d9+Qslov1MMsmz0TlepEUIoExB +XKCOgX1OgwZpgP4rIit2lpUixVLdbmMmPogAOAScnrJNikoSDFKAWiTfJaqYVEJ9 +03hN+IMECIAoJuwrEINsU6ee06NYFb4diGWiHtlJE15ibUNICtv0qf6IkxHHHHV9 +r4M4mkZHAgMBAAECggEABJPdK03v7ajTUWMjiXW/yaCT/VBxJrCUnYtuqS4HG8hd +q6IBdnofcijkvxiEl8NDdNHs+Zy4DI38rNTI4Rah33+RvnBy+1BcLKZqC78QuuLB +D/hqfSccgw7cK5Siw9v6dOtIAERaNy/p2WBkMbLbU+vghkJzh/D6y7BHa1RTq7jy +fWm67q+putp+DxbljG3U+6nMJZLagUr1guXgDQRvimQB6Ebfzqvh4MhOGhkAw/GU +c30RjZnNaChelkv/vSWqlsiFxhGpa238JoHS+oDJ3250mW1Le5/w7I2vBDtZLIaq +u3a+TOolKSkeIBcXQ1AZGR7rUlpbgOq2mUwdxFO9gQKBgQD4cFa92QkRoQGC8yCC ++vW3XlvEDO1oNp3KtdLYbzMNqXRT3WQ+u2Kmcd5ztui1BSt/fhZLUhbMbT9hBqUm +aYN9spORkfJ14VAXuOwT4zSc73rDz2FTq1S27T+hxc0IGjTZjMqsl7n1rksdsBAP +xUriDC3DIeUSo2YSMLMFWt1OfwKBgQDljbsF+2VTe9jxVpLU7sIgPl8wfwVsAB2Z +xzgcvJ2qKjZvUy/+wzJ9CN3bAhVbAOB4POfpiwVGAaoTLZBZLNRqAmlynmqO/ptP +7105hOqbhQeljqdCpyFVhvJkyV5KqeKN+2xHTLehlQa9rdLunj7iW44k2rN5Mb0R +77Or6Z40OQKBgFsU/IgvuMZwy9gRgLrkfQ9UFbqjrqpFU8ZMsNdOtV3t4UsZ4LWr +B3jUSGUOCvTKx26/cDb/CoK6DsFoqUWS63U68iUtZ8HV8AIydsK3ysM6fTyqnBkL +uEw0YN7TYN72lKepmWh7W975nmps8QaHI3QKWQCwPYZ+x14l4ow1CuvLAoGALA5x +kIpZPhaM4nS9JYTVWR7fYg1e2wWCqNrlWA6TK++CFweeNIT+EaU7/yZ9NsQKUMlP +sTDvSCpVm+yowZSrB9WCq27gAKW45TSJbdqmtEZp20pvq4ksCqAlsVY8dJP6WUmh +1GVS8P4LFyhfTVCtvP/ZXhVjUKVNJj4c+6eQp3ECgYEAlNS1Cow3TwL1DQxaHzF4 +/Uh17Cx/N5OZq8zlUTPqneQmb2udplDT3UrJyZ07N7cl0bT6nXTlLQO2Y8xbdUSy +Kj4W0J7Pi5EI3rvrE7UDsueMUF2il78fVxdfSkhVPM6KVPSoGHKYfUDDfdx4cvvz +S+jzBZmDhNiOBD5K6DNkC8g= +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/certs/client.ec.crt b/video/transcoder/src/tests/certs/client.ec.crt new file mode 100644 index 00000000..09d04a5e --- /dev/null +++ b/video/transcoder/src/tests/certs/client.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV2gAwIBAgIUfRh5k6JYsW2u7zi9IU/q2hQuTyQwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQyNTA3 +NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEc+AJ5QsBOZdLosr8Rq78nfu43XRxT5lvqjZ1KVb40UE+mlf4QXJgTh98 +7HYk/zQ60KA29UhqUFKEY9F+ucKfbKOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFBWRCfTY+nPvm0ivJfcOF8006LmfMB8GA1UdIwQYMBaA +FPfRYO+Lj/Tlk7hO+n3GxHinByNrMAoGCCqGSM49BAMCA0gAMEUCIQDe/r15qYfh +uhCTdkmef9M1c/dogrZKZJPFummB/EVKSgIgbEQfFdwHZ0ivDgpIvFqrGuQmjVVo +vLtUKoIA7HY7QOA= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/client.ec.key b/video/transcoder/src/tests/certs/client.ec.key new file mode 100644 index 00000000..eecfabfc --- /dev/null +++ b/video/transcoder/src/tests/certs/client.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgoJEehaCKnug+pV9p +2XNUX0Xc4rBJ7sq6LRJPQ39kfbihRANCAARz4AnlCwE5l0uiyvxGrvyd+7jddHFP +mW+qNnUpVvjRQT6aV/hBcmBOH3zsdiT/NDrQoDb1SGpQUoRj0X65wp9s +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/certs/client.ini b/video/transcoder/src/tests/certs/client.ini new file mode 100644 index 00000000..01771e66 --- /dev/null +++ b/video/transcoder/src/tests/certs/client.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_client + +[v3_client] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/transcoder/src/tests/certs/client.rsa.crt b/video/transcoder/src/tests/certs/client.rsa.crt new file mode 100644 index 00000000..b65ed77e --- /dev/null +++ b/video/transcoder/src/tests/certs/client.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUQjFHjZe3el3ZtBtSdaNL8Hypz4owDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAxKRMbZTWMw4AvLFfB6begU24ugNorcPcLIN0u0eGQgWt +UOtelp9k0FvsgpAZ+uGovPhytY03Qtn0dWKAWD5SMGlIBp8YbqLiKwG/kkLFjDxg +ouFqYzdP1cmaEwcFjHUblQ8YM3c9m4r99ZjbsmQXK8qC/3t1PWxYh34HuB1ftXxz +q+rhJUHXuaiJMqyze9Fx0QED5Lpol25HmMm9Jx1eA9de+YbB1ZnK+LCC7IxEooxs +TguKnWqsoF2QpnBVNr/yJq2h/Z/hLMpd+G0+HO2z87JCCAWQnHTmmJgPtyOX27Pb +EggH4xM4VkSm6DbAh2OEDizlzX6TjoS9PmjKrxcTnwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU2GfevHphiOsu2VKHJFwA5cH8 +/q8wHwYDVR0jBBgwFoAULYXilygDXYi+UFjppyZZLl3UUv8wDQYJKoZIhvcNAQEL +BQADggEBAFI0GRb9R5xMSdBIUHrrgJ5XTYiYNZFRTy7IozD804Dyz3jyx9sr+bRD +G3pcjGmzNT3jSwGIuEX1RXLl4ohQbvppT09+2WlH5O+3+uoYoqXN2aBbS5zuKiWN +/gc488u3/Se4hDq6UfKwuqcMQvvaYOhkjgmwfIAe8TTYNLPYdeuNw1Xxr/1AzXHY +sFGsCXZ9M7Wbt6Ln2gMvGeQMItLXEPRe2jtkZyJ/a6KqXIHvZKDccPLZBv56OfmY +TF54NQPMfONGs2thfVP4OaA7iZGIUjth+c6yq7dID28WiSRwADkZjZ9GVxuGH5c7 +PMY5VrrJptJZfg0/ZiVL8O4THqCiJtE= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/client.rsa.key b/video/transcoder/src/tests/certs/client.rsa.key new file mode 100644 index 00000000..64e61a24 --- /dev/null +++ b/video/transcoder/src/tests/certs/client.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDEpExtlNYzDgC8 +sV8Hpt6BTbi6A2itw9wsg3S7R4ZCBa1Q616Wn2TQW+yCkBn64ai8+HK1jTdC2fR1 +YoBYPlIwaUgGnxhuouIrAb+SQsWMPGCi4WpjN0/VyZoTBwWMdRuVDxgzdz2biv31 +mNuyZBcryoL/e3U9bFiHfge4HV+1fHOr6uElQde5qIkyrLN70XHRAQPkumiXbkeY +yb0nHV4D1175hsHVmcr4sILsjESijGxOC4qdaqygXZCmcFU2v/ImraH9n+Esyl34 +bT4c7bPzskIIBZCcdOaYmA+3I5fbs9sSCAfjEzhWRKboNsCHY4QOLOXNfpOOhL0+ +aMqvFxOfAgMBAAECggEACepA4kuda1Ca7+i/omqjEIEDPfnbBtv315S/R4wpNF4F +6a0chVq/IhRofgLXFIPZVsHuQsI+lZPY1CxlzU1DJqbAV3khKb7zyXhamPzd6h7H +Pp9AFoWc9GooZyo0+OqUu/TZYwrxo8yj1oThGwMZ+f7JkSk/9ZtncVmE+R8eCtAH +bytS4nQufwIgoE8YCde4FLIUkaLrtC9lkJnOz0878A7BScFb3pvI2ZaqV2tFUq01 +gAxFdK2M3jGSGMGnCNCLy2Fjx+kfCWuwikF6PRS4imX1+lTJDNTuv6XmPnHKoe8S +dkaEzI6y/IOzKgQoyH4mZtFA4/7U+4FzuLWr8k4vyQKBgQDOqWvLxBOUqfgewYQL +85pdBoIa3MQpHf8uAeZ0yWut0HbxHQGAygDQDPgLRQhSRP+MXo9saXCOvI8+rCKF +FszK/ux0Ag8mxA+p6RRhlgASDhyEIyEHCxyEEIjP4eqR1rZ6MmIfStUJicW1U4WG +fSecpav+S2YSyIuu3MO6PfJCWQKBgQDzlnursFmcQN+B18onF2/9K1ImmmGYXPS3 +b1lkjleIW8MtQCK9vGlDBD0aBnJDymSxWyWzJK0bn9SmeOjYzdND+tit5rvEUnBp +nYrKlpSSMyvPnLV7xN09asOvhKatLrr0sstcDtmLtSm2dFLOYiryN8NHtPAGzyh9 +MJ1OHCYWtwKBgQCV1pb2xbKgvl/NBOgVtkk8m4Rnr5t2aG5lUDFkicnN23DxvuMh +GtVeA5kwqpuu8qIKh2Eb7JMUmriNa0cYEgDoSc7tCbUsmUj2G62QV66zaJHaaJIA +xlillEtt1lI57WCe1rr4D0zJPqAfqXANo969oA1FMivPAKLuZNhwx4tH+QKBgE+j +zK1WjAXFRA4cslBTnl7EsihC41PAWJY8xppU25OOhOKfjHxCRJwPn7aJkwRNANzn +swy+GgblG86Ny3tO2BrqwbshrBRE69HsGzufPdYK+vD3CHL962OwK2iQUzpeA+wL +JOflRwUhZxDrOUOW3vmwd51TMALZ6h/8LAIku+NDAoGAQY4nwrU2w0pbywmHiVMO +YWqryILqyeq9wSuemSdleph/SgmOkut0L4HeB/G3mpqHtOOOMtri5A+Xy+n/QToc +scJzSPxO21PhqQHWVSxr1VsNQow8PAbLZWVIvLkXXMk4Pw18r8xQMnXCA9xBJc3V +hwL+VmO0zQEX/dtw2uTI4Qs= +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/certs/generate.sh b/video/transcoder/src/tests/certs/generate.sh new file mode 100755 index 00000000..b7e30a66 --- /dev/null +++ b/video/transcoder/src/tests/certs/generate.sh @@ -0,0 +1,23 @@ +openssl genrsa -out ca.rsa.key 2048 +openssl genrsa -out server.rsa.key 2048 +openssl genrsa -out client.rsa.key 2048 + +openssl req -x509 -sha256 -days 365 -nodes -key ca.rsa.key -config ca.ini -out ca.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key server.rsa.key -config server.ini -out server.rsa.crt +openssl req -x509 -sha256 -days 365 -CA ca.rsa.crt -CAkey ca.rsa.key -nodes -key client.rsa.key -config client.ini -out client.rsa.crt + +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out ca.ec.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out server.ec.key +openssl ecparam -outform PEM -name prime256v1 -genkey -noout -out client.ec.key + +openssl pkcs8 -topk8 -nocrypt -in ca.ec.key -out ca.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in server.ec.key -out server.ec.key.pem +openssl pkcs8 -topk8 -nocrypt -in client.ec.key -out client.ec.key.pem + +mv ca.ec.key.pem ca.ec.key +mv server.ec.key.pem server.ec.key +mv client.ec.key.pem client.ec.key + +openssl req -x509 -sha256 -days 365 -nodes -key ca.ec.key -config ca.ini -out ca.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key server.ec.key -config server.ini -out server.ec.crt +openssl req -x509 -sha256 -days 365 -CA ca.ec.crt -CAkey ca.ec.key -nodes -key client.ec.key -config client.ini -out client.ec.crt diff --git a/video/transcoder/src/tests/certs/server.ec.crt b/video/transcoder/src/tests/certs/server.ec.crt new file mode 100644 index 00000000..c601c67b --- /dev/null +++ b/video/transcoder/src/tests/certs/server.ec.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV2gAwIBAgIUM6MqBG5ljzLPkWCkE+QgILdJffMwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQyNTA3 +NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEuVIvvN9VEfcGpYgH/3TuJDqF3uwXHn+EBjLE9xOkP2ubA38Gahii2KFN +1bRY79YKjROoN47Rt5jrlqIC8PnuvKOBjDCBiTAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAUBgNVHREEDTALgglsb2Nh +bGhvc3QwHQYDVR0OBBYEFNYg0oijXGfUkP2kHRXfUtgUNnk8MB8GA1UdIwQYMBaA +FPfRYO+Lj/Tlk7hO+n3GxHinByNrMAoGCCqGSM49BAMCA0gAMEUCIQD/SQQ4poga +bbULoxEVWG0PtXvmtITFWUtTismeMFP5PQIgTtiKw5rmgo2Vas2HrvAMegNAQ4Dm +nAWXjBlgck3dIyc= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/server.ec.key b/video/transcoder/src/tests/certs/server.ec.key new file mode 100644 index 00000000..f1195b67 --- /dev/null +++ b/video/transcoder/src/tests/certs/server.ec.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVK+WX1r5NDJg3RJZ +mR+ZhJ6xD7Mj/N7AQ/kEJsEgy2mhRANCAAS5Ui+831UR9waliAf/dO4kOoXe7Bce +f4QGMsT3E6Q/a5sDfwZqGKLYoU3VtFjv1gqNE6g3jtG3mOuWogLw+e68 +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/certs/server.ini b/video/transcoder/src/tests/certs/server.ini new file mode 100644 index 00000000..34331807 --- /dev/null +++ b/video/transcoder/src/tests/certs/server.ini @@ -0,0 +1,17 @@ +[req] +prompt = no +default_md = sha256 +distinguished_name = dn +x509_extensions = v3_server + +[v3_server] +basicConstraints = critical,CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[dn] +CN = 127.0.0.1 + +[alt_names] +DNS.1 = localhost diff --git a/video/transcoder/src/tests/certs/server.rsa.crt b/video/transcoder/src/tests/certs/server.rsa.crt new file mode 100644 index 00000000..9669a7b1 --- /dev/null +++ b/video/transcoder/src/tests/certs/server.rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUWyHas5CdrlT3JoopnDLC3Cz3h5MwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDQyNjA3NDUwNloXDTI0MDQy +NTA3NDUwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAnXYZQRUPeNcHbY4w/HYv/U1pLruOi0haDg3Jy+0lI66M +YfJSE71TuZyGHPK5u/MJm7qByVyc74jZBnXzc07JpOuDXLsQ3obNgAwSEDQHXrV5 +HDq0zZ3GWbBYd1AZ+iPpRFuStGYrxpF/zITMKM9+GQINneMnnaIJYng5JDWZfyAB ++urnVxjTkvGsGtyFnP5+uJuTrIDWMdkHFgRjNZBTSI7R4QzvdqXuu8K4osDy89SJ +c7XtYf9+wfVEpqwXqwgAjhRlJSFbl4df/egFzzduggTk6KcrbIRaTfWTLPLfHbkF +dzn0yxHFozK9chznOBLAelCKu4p42Ei34KO5VdXFhwIDAQABo4GMMIGJMAwGA1Ud +EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU+1hGS7KCsHYDPNpxeIS7sYE5 +cOAwHwYDVR0jBBgwFoAULYXilygDXYi+UFjppyZZLl3UUv8wDQYJKoZIhvcNAQEL +BQADggEBADUvzMmV+C4Zh2XxPncSeciqXCKPENkvAWxvHWVlaXJKxIhmEMpZH4mR +L9SKWJSt7zmL0aJQUZ/yNU1aJ2mRQwtFfky3ebAcOPsa4HM02fNhgV1b/r84S40o +5fSpgcZCwJQHH7GHABbHJToCz/TxRktVFRJSEaOx0yzCGVCKwHe7+TClawGzL7z1 +6x8KLbXZInbBHEJSNFLckExFZTH09xIG33LaywI9p/6xA3VenCvePDPt2lAeEk9j +w7tus6zSA+/5PHpPOAoOiUUtmXDZGs+4s8sdu6XRAr2eRruvCzrailb9JydyJj7X +Z+QmTOWIU7puyugfBGAj39UlogNwwW8= +-----END CERTIFICATE----- diff --git a/video/transcoder/src/tests/certs/server.rsa.key b/video/transcoder/src/tests/certs/server.rsa.key new file mode 100644 index 00000000..1d4f85f0 --- /dev/null +++ b/video/transcoder/src/tests/certs/server.rsa.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCddhlBFQ941wdt +jjD8di/9TWkuu46LSFoODcnL7SUjroxh8lITvVO5nIYc8rm78wmbuoHJXJzviNkG +dfNzTsmk64NcuxDehs2ADBIQNAdetXkcOrTNncZZsFh3UBn6I+lEW5K0ZivGkX/M +hMwoz34ZAg2d4yedoglieDkkNZl/IAH66udXGNOS8awa3IWc/n64m5OsgNYx2QcW +BGM1kFNIjtHhDO92pe67wriiwPLz1Ilzte1h/37B9USmrBerCACOFGUlIVuXh1/9 +6AXPN26CBOTopytshFpN9ZMs8t8duQV3OfTLEcWjMr1yHOc4EsB6UIq7injYSLfg +o7lV1cWHAgMBAAECggEAMJLy/XG6wSNMRk6b6/WlnjVYIjN3qJ3cMgOs4by5PUWm +RrzS9wXroxGXCa0TANjbeO/TA8QPIZGMqYKPZF3EtJx2fI+0h4D8Oej/VYJHV78R +qlSt29Q1EQvmboAGU3Tqi8zX99Cg5nOSAgxhUqGXm61XeAJQAT+wN3Ew52ule2Jv +EqO83Uj1hDtYoEYBB2FhQfrGd/L8qN2GMnn0W0r3oiC8LlMZBIVxGRJGHAkdhJ8D +46zEmgpPyckS9XNJx4yPIDMDn3B2Yp7it8jruXar3n+SDGa77itcTRwUEsFSxnKC +H1FbTzsFp5e3O4u7OH5iONczykoZpFQ0utQysjxh0QKBgQDZtWcNXq2xqh00X+q4 +gy4Smq4TFtPxipRaewIESAnbI6iWGsyFdP1co4ryoAH8zbIgELQCfgfr7LiQj/A3 +CQfOPB49a8xI4HbXZe6yFgti45GBb8y5RHSeUPPnGACSekif8M4Vaix4w8FagPZa ++ZTQPQA3ddnGVZ5GPODR4wwE3wKBgQC5J/z8/rBPkYXipT7Zben+IzhlXRFWhHgV +afcNdBY0G1JeR37PpDPdPZ5WnSNoMRfu573GyrTOSF4I4jgLS5L8VHEPi5gJA7U7 +Wxv9ppQNRo3SODnhO/Al1369DUSWQwJ6WDjcEO1Xrc0AsIIm8LxhoV5L/CjqwJZ/ +4nrsVKZsWQKBgC9ham8fdt/erQJ0CYpkikdkQJRI+JFt3oGemb7CytpVdWBNrssw +vd9GfHv3VNdnEOgnmnWcZi7zUuurV9Uycu9waAhoCIqnx1VzirJZV9sKueUYps5/ +Vn4KEjruH1nBoUKlzsQcWldiCxeeT39XKAr167EmReIDSjHxF+C18CyzAoGADCjk +JHlVeuRDtq7DgeQGCfqmKYIDMXthp4ZeAzQsgR+KOUbYvSo7fbweOfH38U/IEpiF +jhih5yo5grvYkmVUMd4ZzruMMItdy5ggLnhSIM0RY0zuACy/iLyuRhwo9PVRpFdG +5Kz36VowrGrrIUOOG5tNZhAZX9FmEN/+0qZ8h4ECgYEA0b8giWmBFYElU17r8oRG +g8g4Op3RSuHmJnAylVxahm+CvFeClt0wtxbKAbrXwIfCoI23OyQgUmRrC+zp4V0J +wlKQivOiSUrmhuOCQShkvQdYUHoBOMvkloXq7U0MWfl5pmfKjKcpQLeeNsaOFD0h +jhq59AyODmAFTujOw+8Ly5w= +-----END PRIVATE KEY----- diff --git a/video/transcoder/src/tests/config.rs b/video/transcoder/src/tests/config.rs new file mode 100644 index 00000000..adeb0a65 --- /dev/null +++ b/video/transcoder/src/tests/config.rs @@ -0,0 +1,116 @@ +use serial_test::serial; + +use crate::config::AppConfig; + +fn clear_env() { + for (key, _) in std::env::vars() { + if key.starts_with("SCUF_") { + std::env::remove_var(key); + } + } +} + +#[serial] +#[test] +fn test_parse() { + clear_env(); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config, AppConfig::default()); +} + +#[serial] +#[test] +fn test_parse_env() { + clear_env(); + + std::env::set_var("SCUF_LOGGING__LEVEL", "ingest=debug"); + std::env::set_var( + "SCUF_DATABASE__URI", + "postgres://postgres:postgres@localhost:5433/postgres", + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + assert_eq!(config.logging.level, "ingest=debug"); +} + +#[serial] +#[test] +fn test_parse_file() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "ingest=debug" + +[api] +addresses = [ + "test", + "test2" +] +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "ingest=debug"); + assert_eq!(config.api.addresses, vec!["test", "test2"]); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} + +#[serial] +#[test] +fn test_parse_file_env() { + clear_env(); + + let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let config_file = tmp_dir.path().join("config.toml"); + + std::fs::write( + &config_file, + r#" +[logging] +level = "ingest=debug" + +[transcoder] +socket_dir = "/tmp" + +[api] +addresses = [ + "test", + "test2" +] +"#, + ) + .expect("Failed to write config file"); + + std::env::set_var( + "SCUF_CONFIG_FILE", + config_file.to_str().expect("Failed to get str"), + ); + std::env::set_var("SCUF_LOGGING__LEVEL", "ingest=info"); + + let config = AppConfig::parse().expect("Failed to parse config"); + + assert_eq!(config.logging.level, "ingest=info"); + assert_eq!(config.transcoder.socket_dir, "/tmp".to_string()); + assert_eq!(config.api.addresses, vec!["test", "test2"]); + assert_eq!( + config.config_file, + config_file.to_str().expect("Failed to get str") + ); +} diff --git a/video/transcoder/src/tests/global.rs b/video/transcoder/src/tests/global.rs new file mode 100644 index 00000000..7c120635 --- /dev/null +++ b/video/transcoder/src/tests/global.rs @@ -0,0 +1,47 @@ +use std::{sync::Arc, time::Duration}; + +use common::{ + context::{Context, Handler}, + logging, + prelude::FutureTimeout, +}; +use fred::pool::RedisPool; + +use crate::{config::AppConfig, global::GlobalState}; + +pub async fn mock_global_state(config: AppConfig) -> (Arc, Handler) { + let (ctx, handler) = Context::new(); + + dotenvy::dotenv().ok(); + + logging::init(&config.logging.level, config.logging.json) + .expect("failed to initialize logging"); + + let rmq = common::rmq::ConnectionPool::connect( + std::env::var("RMQ_URL").expect("RMQ_URL not set"), + lapin::ConnectionProperties::default(), + Duration::from_secs(30), + 1, + ) + .timeout(Duration::from_secs(5)) + .await + .expect("failed to connect to rabbitmq") + .expect("failed to connect to rabbitmq"); + + let redis = RedisPool::new( + fred::types::RedisConfig::from_url( + std::env::var("REDIS_URL") + .expect("REDIS_URL not set") + .as_str(), + ) + .expect("failed to parse redis url"), + Some(Default::default()), + Some(Default::default()), + 2, + ) + .expect("failed to create redis pool"); + + let global = Arc::new(GlobalState::new(config, ctx, rmq, redis)); + + (global, handler) +} diff --git a/video/transcoder/src/tests/grpc/health.rs b/video/transcoder/src/tests/grpc/health.rs new file mode 100644 index 00000000..a174980e --- /dev/null +++ b/video/transcoder/src/tests/grpc/health.rs @@ -0,0 +1,107 @@ +use std::time::Duration; + +use common::grpc::make_channel; +use common::prelude::FutureTimeout; + +use crate::config::{AppConfig, GrpcConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_health_check() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_health_watch() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + ..Default::default() + }, + ..Default::default() + }) + .await; + + let handle = tokio::spawn(run(global)); + let channel = make_channel( + vec![format!("http://localhost:{}", port)], + Duration::from_secs(0), + None, + ) + .unwrap(); + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .watch(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + + let mut stream = resp.into_inner(); + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + + let cancel = handler.cancel(); + + let resp = stream.message().await.unwrap().unwrap(); + assert_eq!( + resp.status, + crate::pb::health::health_check_response::ServingStatus::NotServing as i32 + ); + + cancel + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/transcoder/src/tests/grpc/mod.rs b/video/transcoder/src/tests/grpc/mod.rs new file mode 100644 index 00000000..a72ae5cf --- /dev/null +++ b/video/transcoder/src/tests/grpc/mod.rs @@ -0,0 +1,2 @@ +mod health; +mod tls; diff --git a/video/transcoder/src/tests/grpc/tls.rs b/video/transcoder/src/tests/grpc/tls.rs new file mode 100644 index 00000000..94bdbcd7 --- /dev/null +++ b/video/transcoder/src/tests/grpc/tls.rs @@ -0,0 +1,150 @@ +use common::grpc::{make_channel, TlsSettings}; +use common::prelude::FutureTimeout; +use std::path::PathBuf; +use std::time::Duration; +use tonic::transport::{Certificate, Identity}; + +use crate::config::{AppConfig, GrpcConfig, TlsConfig}; +use crate::grpc::run; +use crate::tests::global::mock_global_state; + +#[tokio::test] +async fn test_grpc_tls_rsa() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.rsa.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.rsa.crt").to_str().unwrap().to_string(), + key: dir.join("server.rsa.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = + Certificate::from_pem(std::fs::read_to_string(dir.join("ca.rsa.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.rsa.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.rsa.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} + +#[tokio::test] +async fn test_grpc_tls_ec() { + let port = portpicker::pick_unused_port().expect("failed to pick port"); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/certs"); + + let (global, handler) = mock_global_state(AppConfig { + grpc: GrpcConfig { + bind_address: format!("0.0.0.0:{}", port).parse().unwrap(), + tls: Some(TlsConfig { + cert: dir.join("server.ec.crt").to_str().unwrap().to_string(), + ca_cert: dir.join("ca.ec.crt").to_str().unwrap().to_string(), + key: dir.join("server.ec.key").to_str().unwrap().to_string(), + domain: Some("localhost".to_string()), + }), + }, + ..Default::default() + }) + .await; + + let ca_content = Certificate::from_pem(std::fs::read_to_string(dir.join("ca.ec.crt")).unwrap()); + let client_cert = std::fs::read_to_string(dir.join("client.ec.crt")).unwrap(); + let client_key = std::fs::read_to_string(dir.join("client.ec.key")).unwrap(); + let client_identity = Identity::from_pem(client_cert, client_key); + + let channel = make_channel( + vec![format!("https://localhost:{}", port)], + Duration::from_secs(0), + Some(TlsSettings { + domain: "localhost".to_string(), + ca_cert: ca_content, + identity: client_identity, + }), + ) + .unwrap(); + + let handle = tokio::spawn(async move { + if let Err(e) = run(global).await { + tracing::error!("grpc failed: {}", e); + Err(e) + } else { + Ok(()) + } + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut client = crate::pb::health::health_client::HealthClient::new(channel); + + let resp = client + .check(crate::pb::health::HealthCheckRequest::default()) + .await + .unwrap(); + assert_eq!( + resp.into_inner().status, + crate::pb::health::health_check_response::ServingStatus::Serving as i32 + ); + handler + .cancel() + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel context"); + + handle + .timeout(Duration::from_secs(1)) + .await + .expect("failed to cancel grpc") + .expect("grpc failed") + .expect("grpc failed"); +} diff --git a/video/transcoder/src/tests/mod.rs b/video/transcoder/src/tests/mod.rs new file mode 100644 index 00000000..9a32ded3 --- /dev/null +++ b/video/transcoder/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod config; +mod global; +mod grpc; diff --git a/video/transcoder/src/transcoder/job/mod.rs b/video/transcoder/src/transcoder/job/mod.rs new file mode 100644 index 00000000..2bd1b76c --- /dev/null +++ b/video/transcoder/src/transcoder/job/mod.rs @@ -0,0 +1,597 @@ +use std::io; +use std::process::Output; +use std::{ + os::unix::process::CommandExt, path::Path, pin::pin, process::Command as StdCommand, sync::Arc, + time::Duration, +}; + +use anyhow::{anyhow, Result}; +use common::prelude::*; +use common::vec_of_strings; +use fred::types::Expiration; +use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; +use lapin::message::Delivery; +use lapin::options::BasicAckOptions; +use nix::sys::signal; +use nix::unistd::Pid; +use prost::Message as _; +use tokio::{ + io::AsyncWriteExt, + join, + net::UnixListener, + process::{ChildStdin, Command}, + select, +}; +use tokio_util::sync::CancellationToken; +use tonic::{transport::Channel, Status}; + +use crate::pb::scuffle::types::StreamVariant; +use crate::transcoder::job::utils::{release_lock, set_lock, SharedFuture}; +use crate::{ + global::GlobalState, + pb::scuffle::{ + events::{transcoder_message, TranscoderMessage, TranscoderMessageNewStream}, + video::{ + ingest_client::IngestClient, transcoder_event_request, watch_stream_response, + TranscoderEventRequest, WatchStreamRequest, WatchStreamResponse, + }, + }, +}; +use fred::interfaces::KeysInterface; + +mod track_parser; +mod utils; +mod variant; + +pub async fn handle_message( + global: Arc, + msg: Delivery, + shutdown_token: CancellationToken, +) { + let mut job = match handle_message_internal(&msg).await { + Ok(job) => job, + Err(err) => { + tracing::error!("failed to handle message: {}", err); + return; + } + }; + + if let Err(err) = msg.ack(BasicAckOptions::default()).await { + tracing::error!("failed to ACK message: {}", err); + return; + }; + + job.run(global, shutdown_token).await; +} + +async fn handle_message_internal(msg: &Delivery) -> Result { + let message = TranscoderMessage::decode(msg.data.as_slice())?; + + let req = match message.data { + Some(transcoder_message::Data::NewStream(data)) => data, + None => return Err(anyhow!("message missing data")), + }; + + let channel = common::grpc::make_channel( + vec![req.ingest_address.clone()], + Duration::from_secs(30), + None, + )?; + + tracing::info!("got new stream request: {}", req.stream_id); + + let mut client = IngestClient::new(channel); + + let stream = client + .watch_stream(WatchStreamRequest { + request_id: req.request_id.clone(), + stream_id: req.stream_id.clone(), + }) + .timeout(Duration::from_secs(2)) + .await?? + .into_inner(); + + Ok(Job { + req, + client, + stream, + lock_owner: CancellationToken::new(), + }) +} + +struct Job { + req: TranscoderMessageNewStream, + client: IngestClient, + stream: tonic::Streaming, + lock_owner: CancellationToken, +} + +#[inline(always)] +fn redis_mutex_key(stream_id: &str) -> String { + format!("transcoder:{}:mutex", stream_id) +} + +#[inline(always)] +fn redis_master_playlist_key(stream_id: &str) -> String { + format!("transcoder:{}:playlist", stream_id) +} + +fn set_master_playlist( + global: Arc, + stream_id: &str, + variants: &[StreamVariant], + lock: CancellationToken, +) -> impl futures::Future> + Send + 'static { + let playlist_key = redis_master_playlist_key(stream_id); + + let mut playlist = String::new(); + + playlist.push_str("#EXTM3U\n"); + + // Find audio only variant + let audio_variant = variants + .iter() + .find(|v| v.video_settings.is_none()) + .expect("no audio only variant found"); + + playlist.push_str(&format!("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"{}\",NAME=\"{}\",AUTOSELECT=YES,DEFAULT=YES,URI=\"{}/index.m3u8\"\n", audio_variant.name, audio_variant.name, audio_variant.id)); + + for variant in variants { + let mut options = vec![ + format!( + "BANDWIDTH={}", + variant + .audio_settings + .as_ref() + .map(|a| a.bitrate) + .unwrap_or_default() + + variant + .video_settings + .as_ref() + .map(|v| v.bitrate) + .unwrap_or_default() + ), + format!( + "CODECS=\"{}\"", + variant + .video_settings + .as_ref() + .map(|v| v.codec.clone()) + .into_iter() + .chain( + variant + .audio_settings + .as_ref() + .map(|a| a.codec.clone()) + .into_iter() + ) + .collect::>() + .join(",") + ), + ]; + + options.push(format!("AUDIO=\"{}\"", audio_variant.name)); + + if let Some(video_settings) = &variant.video_settings { + options.push(format!( + "RESOLUTION={}x{}", + video_settings.width, video_settings.height + )); + options.push(format!("FRAME-RATE={}", video_settings.framerate)); + options.push(format!("VIDEO=\"{}\"", variant.name)); + playlist.push_str(format!("#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID=\"{}\",NAME=\"{}\",AUTOSELECT=YES,DEFAULT=YES\n", variant.name, variant.name).as_str()); + } + + playlist.push_str(&format!("#EXT-X-STREAM-INF:{}\n", options.join(","))); + playlist.push_str(&format!("{}/index.m3u8\n", variant.id)) + } + + async move { + lock.cancelled().await; + + global + .redis + .set( + &playlist_key, + playlist, + Some(Expiration::EX(450)), + None, + false, + ) + .await?; + + let mut ticker = tokio::time::interval(Duration::from_secs(60)); + loop { + ticker.tick().await; + global.redis.expire(&playlist_key, 450).await?; + } + } +} + +impl Job { + async fn run(&mut self, global: Arc, shutdown_token: CancellationToken) { + tracing::info!("starting transcode job"); + let mut set_lock_fut = pin!(set_lock( + global.clone(), + redis_mutex_key(&self.req.stream_id), + self.req.request_id.clone(), + self.lock_owner.clone(), + )); + + let mut update_playlist_fut = pin!(set_master_playlist( + global.clone(), + &self.req.stream_id, + &self.req.variants, + self.lock_owner.child_token(), + )); + + // We need to create a unix socket for ffmpeg to connect to. + let socket_dir = Path::new(&global.config.transcoder.socket_dir).join(&self.req.request_id); + if let Err(err) = tokio::fs::create_dir_all(&socket_dir).await { + tracing::error!("failed to create socket dir: {}", err); + self.report_error("Failed to create socket dir", false) + .await; + return; + } + + let mut futures = FuturesUnordered::new(); + + for v in &self.req.variants { + let socket = match UnixListener::bind(socket_dir.join(format!("{}.sock", v.id))) { + Ok(s) => s, + Err(err) => { + tracing::error!("failed to bind socket: {}", err); + self.report_error("Failed to bind socket", false).await; + return; + } + }; + + futures.push(variant::handle_variant( + global.clone(), + self.req.stream_id.clone(), + v.id.clone(), + self.req.request_id.clone(), + socket, + )); + } + + let custom_variants = self + .req + .variants + .iter() + .filter(|v| v.name != "source" && v.video_settings.is_some()) + .collect::>(); + + let Some(source_variant) = self + .req + .variants + .iter() + .find(|v| v.name == "source") else { + self.report_error("no source variant", true).await; + tracing::error!("no source variant"); + return; + }; + + let Some(audio_variant) = self + .req + .variants + .iter() + .find(|v| v.name == "audio") else { + self.report_error("no audio variant", true).await; + tracing::error!("no audio variant"); + return; + }; + + let filter_graph = custom_variants + .iter() + .enumerate() + .map(|(i, v)| { + let video = v + .video_settings + .as_ref() + .expect("video settings checked above"); + + let previous = if i == 0 { + "[0:v]".to_string() + } else { + format!("[{}_out]", i - 1) + }; + + format!( + "{}scale={}:{},pad=ceil(iw/2)*2:ceil(ih/2)*2{}", + previous, + video.width, + video.height, + if i == custom_variants.len() - 1 { + format!("[{}]", v.name) + } else { + format!(",split=2[{}][{}_out]", v.name, i) + } + ) + }) + .collect::>() + .join(";"); + + // We need to build a ffmpeg command. + let Some(audio_settings) = audio_variant + .audio_settings + .as_ref() else { + self.report_error("no audio settings", true).await; + tracing::error!("no audio settings"); + return; + }; + + const MP4_FLAGS: &str = "+frag_keyframe+empty_moov+default_base_moof"; + + #[rustfmt::skip] + let mut args = vec_of_strings![ + "-v", "error", + "-i", "-", + "-probesize", "250M", + "-analyzeduration", "250M", + "-map", "0:v", + "-c:v", "copy", + "-f", "mp4", + "-movflags", MP4_FLAGS, + "-frag_duration", "1", + format!( + "unix://{}", + socket_dir + .join(format!("{}.sock", source_variant.id)) + .display() + ), + "-map", "0:a", + "-c:a", "libopus", + "-b:a", format!("{}", audio_settings.bitrate), + "-ac:a", format!("{}", audio_settings.channels), + "-ar:a", format!("{}", audio_settings.sample_rate), + "-f", "mp4", + "-movflags", MP4_FLAGS, + "-frag_duration", "1", + format!( + "unix://{}", + socket_dir + .join(format!("{}.sock", audio_variant.id)) + .display() + ), + ]; + + if !filter_graph.is_empty() { + args.extend(vec_of_strings!["-filter_complex", filter_graph]); + } + + for v in custom_variants { + let video = v.video_settings.as_ref().expect("video settings"); + + #[rustfmt::skip] + args.extend(vec_of_strings![ + "-map", format!("[{}]", v.name), + "-c:v", "libx264", + "-preset", "medium", + "-b:v", format!("{}", video.bitrate), + "-maxrate", format!("{}", video.bitrate), + "-bufsize", format!("{}", video.bitrate * 2), + "-profile:v", "high", + "-level:v", "5.1", + "-pix_fmt", "yuv420p", + "-g", format!("{}", video.framerate * 2), + "-keyint_min", format!("{}", video.framerate * 2), + "-sc_threshold", "0", + "-r", format!("{}", video.framerate), + "-crf", "23", + "-tune", "zerolatency", + "-f", "mp4", + "-movflags", MP4_FLAGS, + "-frag_duration", "1", + format!( + "unix://{}", + socket_dir.join(format!("{}.sock", v.id)).display() + ), + ]); + } + + let mut child = StdCommand::new("ffmpeg"); + + child + .args(&args) + .stdin(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .process_group(0); + + let mut child = match Command::from(child).spawn() { + Ok(c) => c, + Err(err) => { + tracing::error!("failed to spawn ffmpeg: {}", err); + self.report_error("failed to spawn ffmpeg", false).await; + return; + } + }; + + let mut stdin = child.stdin.take().expect("failed to get stdin"); + + let pid = match child.id() { + Some(pid) => Pid::from_raw(pid as i32), + None => { + tracing::error!("failed to get pid"); + self.report_error("failed to get pid", false).await; + return; + } + }; + + let child = pin!(child.wait_with_output()); + let mut child = SharedFuture::new(child); + + let mut shutdown_fuse = pin!(shutdown_token.cancelled().fuse()); + + while select! { + _ = &mut shutdown_fuse => { + self.client.transcoder_event(TranscoderEventRequest { + request_id: self.req.request_id.clone(), + stream_id: self.req.stream_id.clone(), + event: Some(transcoder_event_request::Event::ShuttingDown(true)), + }).await.is_ok() + }, + msg = self.stream.next() => self.handle_msg(msg, &mut stdin).await, + // When FFmpeg exits, we need to exit as well. + // This is almost always because the stream was closed. + // So we don't need to report an error, however we check the exit code in the complete_loop function. + // If the exit code is not 0, we report an error. + r = &mut child => { + tracing::info!("ffmpeg exited: {:?}", r); + false + }, + // This shutting down usually implies that the stream was closed. + // So we don't need to report an error. + r = &mut set_lock_fut => { + if let Err(err) = r { + tracing::error!("set lock error: {:#}", err); + } else { + tracing::warn!("set lock done prematurely without error"); + } + false + }, + _ = &mut update_playlist_fut => { + tracing::info!("playlist update shutdown while running"); + false + }, + // This shutting down usually implies that the stream was closed. + // So we only report an error if the stream was not closed. + f = futures.next() => { + tracing::info!("variant stream shutdown while running"); + if f.unwrap().is_err() { + self.report_error("variant stream shutdown while running", true).await; + } + false + }, + } {} + + tracing::info!("shutting down stream"); + drop(stdin); + signal::kill(pid, signal::Signal::SIGTERM).ok(); + + select! { + r = self.complete_loop(pid, child, futures.collect::>()).timeout(Duration::from_secs(5)) => { + if let Err(err) = r { + tracing::error!("failed to complete loop: {:#}", err); + self.report_error("failed to complete loop", false).await; + } + }, + r = set_lock_fut => { + if let Err(err) = r { + tracing::error!("set lock error: {:#}", err); + } else { + tracing::warn!("set lock done prematurely without error"); + } + }, + } + + if let Err(err) = release_lock( + &global, + &redis_mutex_key(&self.req.stream_id), + &self.req.request_id, + ) + .await + { + tracing::error!("failed to release lock: {:#}", err); + }; + + tracing::info!("stream shut down"); + } + + async fn complete_loop( + &mut self, + pid: Pid, + ffmpeg: impl futures::Future>> + Unpin, + variants: impl futures::Future + Unpin, + ) { + tracing::info!("waiting for ffmpeg to exit"); + + let timeout = ffmpeg.timeout(Duration::from_secs(2)).then(|r| async move { + if let Ok(r) = r { + tracing::info!("ffmpeg exited: {:?}", r); + + match r.as_ref() { + Ok(r) => !r.status.success(), + Err(_) => true, + } + } else { + tracing::warn!("ffmpeg did not exit in time, killing"); + if let Err(err) = signal::kill(pid, signal::Signal::SIGKILL) { + tracing::error!("failed to kill ffmpeg: {}", err); + } + + true + } + }); + + let (failed, _) = join!(timeout, variants); + if failed { + self.report_error("ffmpeg exited with non-zero status", false) + .await; + } + } + + async fn handle_msg( + &mut self, + msg: Option>, + stdin: &mut ChildStdin, + ) -> bool { + let msg = match msg { + Some(Ok(msg)) => msg.data, + _ => { + // We should have gotten a shutting down event + // TODO: report this to API server + tracing::error!("unexpected stream closed"); + return false; + } + }; + + let Some(msg) = msg else { + tracing::error!("recieved empty response"); + return false; + }; + + match msg { + watch_stream_response::Data::InitSegment(data) => { + if stdin.write_all(&data).await.is_err() { + // This is almost always because ffmpeg crashed + // We report an error when we check the exit code + return false; + } + } + watch_stream_response::Data::MediaSegment(ms) => { + if stdin.write_all(&ms.data).await.is_err() { + // This is almost always because ffmpeg crashed + // We report an error when we check the exit code + return false; + } + } + watch_stream_response::Data::ShuttingDown(stream) => { + tracing::info!(stream = stream, "shutting down"); + return false; + } + } + + true + } + + async fn report_error(&mut self, err: impl ToString + Send + Sync, fatal: bool) { + if let Err(err) = self + .client + .transcoder_event(TranscoderEventRequest { + request_id: self.req.request_id.clone(), + stream_id: self.req.stream_id.clone(), + event: Some(transcoder_event_request::Event::Error( + transcoder_event_request::Error { + message: err.to_string(), + fatal, + }, + )), + }) + .await + { + tracing::error!("failed to report error: {}", err); + } + } +} diff --git a/video/transcoder/src/transcoder/job/track_parser.rs b/video/transcoder/src/transcoder/job/track_parser.rs new file mode 100644 index 00000000..a8f4f7bc --- /dev/null +++ b/video/transcoder/src/transcoder/job/track_parser.rs @@ -0,0 +1,131 @@ +use std::io; + +use anyhow::anyhow; +use async_stream::stream; +use bytes::{Buf, Bytes, BytesMut}; +use bytesio::bytes_reader::BytesCursor; +use futures_util::{Stream, StreamExt}; +use mp4::{ + types::{moov::Moov, trun::TrunSample}, + DynBox, +}; + +#[derive(Debug, Clone)] +pub enum TrackOut { + // a Ftyp and Moov box are always sent at the start of a stream + Moov(Moov), + // A moof and mdat box are sent for each segment + Sample(TrackSample), +} + +#[derive(Debug, Clone)] +pub struct TrackSample { + pub duration: u32, + pub keyframe: bool, + pub sample: TrunSample, + pub data: Bytes, +} + +pub fn track_parser( + mut input: impl Stream> + Unpin, +) -> impl Stream> { + stream! { + let mut buffer = BytesMut::new(); + + // Main loop for parsing the stream + while let Some(data) = input.next().await { + buffer.extend_from_slice(&data?); + let mut cursor = io::Cursor::new(buffer.split().freeze()); + + while cursor.has_remaining() { + let position = cursor.position() as usize; + let b = match mp4::DynBox::demux(&mut cursor) { + Ok(b) => b, + Err(e) => { + if e.kind() == io::ErrorKind::UnexpectedEof { + // We need more data to parse this box + cursor.set_position(position as u64); + break; + } else { + yield Err(e); + return; + } + } + }; + + match b { + mp4::DynBox::Moov(moov) => { + if moov.traks.len() != 1 { + yield Err(io::Error::new(io::ErrorKind::InvalidData, anyhow!("moov box must have exactly one trak box"))); + return; + } + + yield Ok(TrackOut::Moov(moov)); + }, + mp4::DynBox::Moof(moof) => { + if moof.traf.len() != 1 { + yield Err(io::Error::new(io::ErrorKind::InvalidData, anyhow!("moof box must have exactly one traf box"))); + return; + } + + let traf = &moof.traf[0]; + let trun = traf.trun.as_ref().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, anyhow!("traf box must have a trun box")))?; + let tfhd = &traf.tfhd; + + let samples = trun.samples.iter().enumerate().map(|(idx, sample)| { + let mut sample = sample.clone(); + sample.duration = sample.duration.or(tfhd.default_sample_duration); + sample.size = sample.size.or(tfhd.default_sample_size); + sample.flags = Some(sample.flags.or(if idx == 0 { trun.first_sample_flags } else { None }).or(tfhd.default_sample_flags).unwrap_or_default()); + sample.composition_time_offset = Some(sample.composition_time_offset.unwrap_or_default()); + sample + }); + + // Get the mdat box + let mdat = match mp4::DynBox::demux(&mut cursor) { + Ok(DynBox::Mdat(mdat)) => mdat, + Ok(_) => { + yield Err(io::Error::new(io::ErrorKind::InvalidData, anyhow!("moof box must be followed by an mdat box"))); + return; + }, + Err(e) => { + if e.kind() == io::ErrorKind::UnexpectedEof { + // We need more data to parse this box + cursor.set_position(position as u64); + break; + } else { + yield Err(e); + return; + } + } + }; + + if mdat.data.len() != 1 { + yield Err(io::Error::new(io::ErrorKind::InvalidData, anyhow!("mdat box must have exactly one data box"))); + return; + } + + let mut mdat_cursor = io::Cursor::new(mdat.data[0].clone()); + for sample in samples { + let data = if let Some(size) = sample.size { + mdat_cursor.read_slice(size as usize).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, anyhow!("mdat data size not big enough for sample: {}", e)))? + } else { + mdat_cursor.get_remaining() + }; + + yield Ok(TrackOut::Sample(TrackSample { + duration: sample.duration.unwrap_or_default(), + keyframe: sample.flags.map(|f| f.sample_depends_on == 2).unwrap_or_default(), + sample, + data, + })); + } + }, + _ => {}, + } + } + + buffer.extend_from_slice(&cursor.get_remaining()); + } + } +} diff --git a/video/transcoder/src/transcoder/job/utils.rs b/video/transcoder/src/transcoder/job/utils.rs new file mode 100644 index 00000000..37b02b50 --- /dev/null +++ b/video/transcoder/src/transcoder/job/utils.rs @@ -0,0 +1,144 @@ +use crate::global::GlobalState; +use anyhow::{anyhow, Result}; +use async_stream::stream; +use bytes::Bytes; +use bytesio::{bytesio::BytesIO, bytesio_errors::BytesIOError}; +use fred::interfaces::KeysInterface; +use fred::types::{Expiration, SetOptions}; +use futures_util::FutureExt; +use std::{io, sync::Arc}; +use tokio::net::UnixListener; +use tokio_util::sync::CancellationToken; + +pub fn unix_stream( + listener: UnixListener, + buffer_size: usize, +) -> impl futures::Stream> { + stream! { + let (sock, _) = match listener.accept().await { + Ok(connection) => connection, + Err(err) => { + yield Err(err); + return; + } + }; + + let mut bio = BytesIO::with_capacity(sock, buffer_size); + + loop { + match bio.read().await { + Ok(bytes) => { + yield Ok(bytes.freeze()); + }, + Err(err) => { + match err { + BytesIOError::ClientClosed => { + return; + }, + _ => { + yield Err(io::Error::new(io::ErrorKind::UnexpectedEof, anyhow!("failed to read from socket: {}", err))); + } + } + } + } + } + } +} + +pub struct SharedFuture> { + inner: F, + output: Option>, +} + +impl + Unpin> SharedFuture { + pub fn new(inner: F) -> Self { + Self { + inner, + output: None, + } + } +} + +impl + Unpin> futures::Future for SharedFuture { + type Output = Arc; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.get_mut(); + if let Some(output) = this.output.as_ref() { + return std::task::Poll::Ready(output.clone()); + } + + let output = futures::ready!(this.inner.poll_unpin(cx)); + let output = Arc::new(output); + this.output = Some(output.clone()); + std::task::Poll::Ready(output) + } +} + +pub async fn set_lock( + global: Arc, + key: String, + req_id: String, + owned: CancellationToken, +) -> Result<()> { + loop { + let have_lock: String = global + .redis + .set( + &key, + &req_id, + Some(Expiration::EX(5)), + Some(SetOptions::NX), + true, + ) + .await?; + if have_lock == req_id { + break; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + owned.cancel(); + + let mut timer = tokio::time::interval(tokio::time::Duration::from_secs(1)); + loop { + timer.tick().await; + + let lock_owner: String = global + .redis + .set( + &key, + &req_id, + Some(Expiration::EX(5)), + Some(SetOptions::XX), + true, + ) + .await?; + if lock_owner != req_id { + return Err(anyhow!("lost lock")); + } + } +} + +pub async fn release_lock(global: &Arc, key: &str, request_id: &str) -> Result<()> { + let lock_owner: String = global + .redis + .set( + key, + request_id, + Some(Expiration::EX(5)), + Some(SetOptions::XX), + true, + ) + .await?; + + if lock_owner == request_id { + global.redis.del(key).await?; + } + + Ok(()) +} diff --git a/video/transcoder/src/transcoder/job/variant/consts.rs b/video/transcoder/src/transcoder/job/variant/consts.rs new file mode 100644 index 00000000..55f7fd76 --- /dev/null +++ b/video/transcoder/src/transcoder/job/variant/consts.rs @@ -0,0 +1,38 @@ +pub const ACTIVE_EXPIRE_SECONDS: i64 = 450; +pub const INACTIVE_EXPIRE_SECONDS: i64 = 4; +pub const ACTIVE_SEGMENT_COUNT: u32 = 4; // segments +pub const ACTIVE_FRAGMENT_SEGMENT_COUNT: u32 = 2; // segments +pub const FRAGMENT_CUT_TARGET_DURATION: f64 = 0.25; // seconds +pub const FRAGMENT_CUT_MAX_DURATION: f64 = 0.35; // seconds +pub const SEGMENT_CUT_TARGET_DURATION: f64 = 2.0; // seconds + +#[inline(always)] +pub fn redis_init_key(stream_id: &str, variant_id: &str) -> String { + format!("transcoder:{}:{}:init", stream_id, variant_id) +} + +#[inline(always)] +pub fn redis_mutex_key(stream_id: &str, variant_id: &str) -> String { + format!("transcoder:{}:{}:mutex", stream_id, variant_id) +} + +#[inline(always)] +pub fn redis_state_key(stream_id: &str, variant_id: &str) -> String { + format!("transcoder:{}:{}:state", stream_id, variant_id) +} + +#[inline(always)] +pub fn redis_segment_state_key(stream_id: &str, variant_id: &str, segment_idx: u32) -> String { + format!( + "transcoder:{}:{}:{}:state", + stream_id, variant_id, segment_idx + ) +} + +#[inline(always)] +pub fn redis_segment_data_key(stream_id: &str, variant_id: &str, segment_idx: u32) -> String { + format!( + "transcoder:{}:{}:{}:data", + stream_id, variant_id, segment_idx + ) +} diff --git a/video/transcoder/src/transcoder/job/variant/mod.rs b/video/transcoder/src/transcoder/job/variant/mod.rs new file mode 100644 index 00000000..7431a7bb --- /dev/null +++ b/video/transcoder/src/transcoder/job/variant/mod.rs @@ -0,0 +1,895 @@ +use crate::global::GlobalState; +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use bytesio::bytes_writer::BytesWriter; +use chrono::SecondsFormat; +use common::prelude::FutureTimeout; +use fred::{ + prelude::{HashesInterface, KeysInterface}, + types::{Expiration, RedisValue}, +}; +use futures_util::StreamExt; +use mp4::{ + types::{ + ftyp::{FourCC, Ftyp}, + mdat::Mdat, + mfhd::Mfhd, + moof::Moof, + moov::Moov, + mvex::Mvex, + mvhd::Mvhd, + tfdt::Tfdt, + tfhd::Tfhd, + traf::Traf, + trex::Trex, + trun::Trun, + }, + BoxType, +}; +use std::{collections::HashMap, io, pin::pin, sync::Arc, time::Duration}; +use tokio::{net::UnixListener, select}; +use tokio_util::sync::CancellationToken; + +use super::{ + track_parser::{track_parser, TrackOut, TrackSample}, + utils::{release_lock, set_lock, unix_stream}, +}; + +mod consts; +mod state; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Operation { + Init, + Fragments, +} + +#[derive(Default, Clone)] +struct TrackState { + moov: Option, + timescale: u32, + samples: Vec<(usize, TrackSample)>, +} + +struct Variant { + stream_id: String, + variant_id: String, + request_id: String, + operation: Operation, + lock_owner: CancellationToken, + tracks: Vec, + redis_state: state::PlaylistState, + should_discontinuity: bool, + segment_state: HashMap)>, +} + +pub async fn handle_variant( + global: Arc, + stream_id: String, + variant_id: String, + request_id: String, + track: UnixListener, +) -> Result { + let mut variant = Variant::new(1, stream_id, variant_id, request_id); + + variant + .run( + global, + pin!(track_parser(pin!(unix_stream(track, 256 * 1024))).map(|r| (r, 1))), + ) + .await?; + + Ok(variant.variant_id) +} + +impl Variant { + pub fn new(trak_count: u32, stream_id: String, variant_id: String, request_id: String) -> Self { + Self { + stream_id, + variant_id, + request_id, + tracks: vec![TrackState::default(); trak_count as usize], + operation: Operation::Init, + lock_owner: CancellationToken::new(), + redis_state: state::PlaylistState::default(), + segment_state: HashMap::new(), + should_discontinuity: false, + } + } + + #[tracing::instrument(skip(self, global, tracks), fields(stream_id = %self.stream_id, variant_id = %self.variant_id, request_id = %self.request_id))] + pub async fn run( + &mut self, + global: Arc, + tracks: impl futures::Stream, u32)> + Unpin, + ) -> Result<(), ()> { + let mut set_lock_fut = pin!(set_lock( + global.clone(), + consts::redis_mutex_key(&self.stream_id, &self.variant_id), + self.request_id.clone(), + self.lock_owner.clone(), + )); + + let mut tracks = tracks.enumerate(); + + let mut result = Ok(()); + + loop { + select! { + item = tracks.next() => { + match item { + Some((_, (Ok(TrackOut::Moov(moov)), track_id))) => { + let idx = track_id as usize - 1; + + if self.tracks.len() <= idx { + tracing::error!("track {} unexpected but moov received", track_id); + result = Err(()); + break; + } + + self.tracks[idx].moov = Some(moov); + } + Some((stream_idx, (Ok(TrackOut::Sample(sample)), track_id))) => { + let idx = track_id as usize - 1; + + if self.tracks.len() <= idx { + tracing::error!("track {:#} unexpected but moov received", track_id); + result = Err(()); + break; + } + + self.tracks[idx].samples.push((stream_idx, sample)); + } + Some((_, (Err(err), idx))) => { + tracing::error!("track {} error: {:#}", idx, err); + result = Err(()); + break; + } + None => { + tracing::debug!("tracks closed"); + break; + } + } + } + r = &mut set_lock_fut => { + if let Err(err) = r { + tracing::error!("set lock error: {:#}", err); + } else { + tracing::warn!("set lock done prematurely without error"); + } + + break; + } + } + + select! { + r = self.process(&global) => { + if let Err(err) = r { + tracing::error!("process error: {:#}", err); + result = Err(()); + break; + } + } + r = &mut set_lock_fut => { + if let Err(err) = r { + tracing::error!("set lock error: {:#}", err); + } else { + tracing::warn!("set lock done prematurely without error"); + } + + break; + } + } + } + + if let Err(err) = self.handle_shutdown(&global).await { + tracing::error!("handle shutdown error: {:#}", err); + } + + if let Err(err) = release_lock( + &global, + &consts::redis_mutex_key(&self.stream_id, &self.variant_id), + &self.request_id, + ) + .await + { + tracing::error!("release lock error: {:#}", err); + } + + tracing::info!("variant {} done", self.variant_id); + + result + } + + async fn handle_shutdown(&mut self, global: &Arc) -> Result<()> { + if self.operation == Operation::Init { + return Ok(()); + } + + let samples = self + .tracks + .iter_mut() + .map(|track| track.samples.drain(..).map(|s| s.1).collect::>()) + .collect::>(); + if samples.iter().any(|s| !s.is_empty()) { + tracing::info!( + "flushing remaining samples {:?}", + samples.iter().map(|s| s.len()).collect::>() + ); + if let std::collections::hash_map::Entry::Vacant(e) = self + .segment_state + .entry(self.redis_state.current_segment_idx()) + { + // This really sucks because we have to create an entire new segment for these few ending samples + self.redis_state.set_current_fragment_idx(0); + e.insert(Default::default()); + } + + self.create_fragment(samples)?; + } + + self.segment_state + .get_mut(&self.redis_state.current_segment_idx()) + .unwrap() + .0 + .set_ready(true); + self.redis_state.set_current_fragment_idx(0); + self.redis_state + .set_current_segment_idx(self.redis_state.current_segment_idx() + 1); + + let pipeline = global.redis.pipeline(); + if self.update_keys(&pipeline).await? { + self.refresh_keys(&pipeline).await?; + pipeline.all().await?; + } + + Ok(()) + } + + async fn process(&mut self, global: &Arc) -> Result<()> { + match self.operation { + Operation::Init => { + self.construct_init(global).await?; + } + Operation::Fragments => { + self.handle_sample(global).await?; + + let pipeline = global.redis.pipeline(); + if self.update_keys(&pipeline).await? { + self.refresh_keys(&pipeline).await?; + pipeline.all().await?; + } + } + } + + Ok(()) + } + + async fn construct_init(&mut self, global: &Arc) -> Result<()> { + if self.tracks.iter().any(|track| track.moov.is_none()) { + return Ok(()); + } + + let (traks, trexs) = self + .tracks + .iter_mut() + .enumerate() + .map(|(idx, track)| { + let track_id = idx as u32 + 1; + let mut moov = track.moov.take().unwrap(); + + if moov.traks.len() != 1 { + return Err(anyhow!("expected 1 trak")); + } + + let mut trak = moov.traks.remove(0); + + trak.edts = None; + trak.tkhd.track_id = track_id; + + track.timescale = trak.mdia.mdhd.timescale; + + Ok((trak, Trex::new(track_id))) + }) + .collect::>>()? + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + let ftyp = Ftyp::new( + FourCC::Iso5, + 512, + vec![FourCC::Iso5, FourCC::Iso6, FourCC::Mp41], + ); + let moov = Moov::new( + Mvhd::new(0, 0, 1000, 0, 2), + traks, + Some(Mvex::new(trexs, None)), + ); + + let mut writer = BytesWriter::default(); + ftyp.mux(&mut writer)?; + moov.mux(&mut writer)?; + + self.initial_redis_state(global, writer.dispose()).await?; + + self.operation = Operation::Fragments; + + Ok(()) + } + + async fn initial_redis_state( + &mut self, + global: &Arc, + init_segment: Bytes, + ) -> Result<()> { + // At this point we know enough about the stream to look at redis to see if we are resuming, or starting fresh. + // If we are resuming we need to load some state about what we have already sent to the client. + // If we are starting fresh we need to create some state so that we can resume later (if needed). + // We also need to make sure that the previous instance is finished, if not we need to wait for it to finish. + if self + .lock_owner + .cancelled() + .timeout(Duration::from_secs(5)) + .await + .is_err() + { + return Err(anyhow!("timeout waiting for lock")); + } + + // We now are the proud owner of the stream and can do whatever we want with it! + // Get the redis state for the stream. + let state: HashMap = global + .redis + .hgetall(consts::redis_state_key(&self.stream_id, &self.variant_id)) + .await + .context("failed to get redis state")?; + + if !state.is_empty() { + // We need to validate the state we got from redis. + let state = state::PlaylistState::from(state); + if self.tracks.len() != state.track_count() { + return Err(anyhow!("track count mismatch")); + } + + for (idx, track) in self.tracks.iter().enumerate() { + if track.timescale != state.track_timescale(idx).unwrap_or(0) { + return Err(anyhow!("track {} timescale mismatch", idx)); + } + } + + self.redis_state = state; + } else { + self.redis_state = state::PlaylistState::default(); + self.tracks.iter().for_each(|track| { + self.redis_state.insert_track(state::Track { + duration: 0, + timescale: track.timescale, + }); + }); + } + + // Since we now know the redis_state we can see if we are starting fresh or resuming. + if self.redis_state.current_segment_idx() != 0 + || self.redis_state.current_fragment_idx() != 0 + { + // We now need to fetch the segments from redis. + let start_idx = (self.redis_state.current_segment_idx() as i32 - 4).max(0) as u32; + let end_idx = self.redis_state.current_segment_idx() + + if self.redis_state.current_fragment_idx() == 0 { + 0 + } else { + 1 + }; + for idx in start_idx..end_idx { + let segment: HashMap = global + .redis + .hgetall(consts::redis_segment_state_key( + &self.stream_id, + &self.variant_id, + idx, + )) + .await + .context("failed to get redis segment state")?; + let segment = state::SegmentState::from(segment); + self.segment_state.insert(idx, (segment, HashMap::new())); + } + } + + let pipeline = global.redis.pipeline(); + + let _: RedisValue = pipeline + .set( + consts::redis_init_key(&self.stream_id, &self.variant_id), + init_segment, + Some(Expiration::EX(consts::ACTIVE_EXPIRE_SECONDS)), + None, + false, + ) + .await + .context("failed to set redis init")?; + + self.update_keys(&pipeline) + .await + .context("failed to update redis keys")?; + + self.refresh_keys(&pipeline) + .await + .context("failed to refresh redis keys")?; + + let _: Vec<()> = pipeline + .all() + .await + .context("failed to execute redis pipeline")?; + + self.should_discontinuity = self.redis_state.current_fragment_idx() != 0; + + Ok(()) + } + + async fn handle_sample(&mut self, _global: &Arc) -> Result<()> { + // We need to check if we have enough samples to create a fragment. + // And then check if we do, if we have enough fragments to create a segment. + if self.should_discontinuity { + // Discontinuities are a bit of a special case. + // Any samples before the keyframe on track 1 are discarded. + // Samples on other tracks will be added to the next fragment. + let Some(idx) = self.tracks[0].samples.iter().position(|(_, sample)| sample.keyframe) else { + // We dont have a place to create a discontinuity yet, so we need to wait a little bit. + return Ok(()); + }; + + // We need to discard all samples on track[0] before the keyframe. (there shouldnt be any, but just in case) + self.tracks[0].samples.drain(..idx); + + self.redis_state.set_current_fragment_idx(0); + let current_idx = self.redis_state.current_segment_idx(); + + self.redis_state.set_current_segment_idx(current_idx + 1); + self.redis_state + .set_discontinuity_sequence(self.redis_state.discontinuity_sequence() + 1); + + // make sure the previous segment is marked as ready. + if let Some((previous, _)) = self.segment_state.get_mut(¤t_idx) { + previous.set_ready(true); + } + + self.should_discontinuity = false; + let mut segment = state::SegmentState::default(); + segment.set_discontinuity(true); + self.segment_state.insert( + self.redis_state.current_segment_idx(), + (segment, HashMap::new()), + ); + } + + // We need to check if we have enough samples to create a new fragment + // We only care about the duration from track 1. Other tracks will just be added regardless if they cut or not. + + // We need to also know about the current segment duration incase we can cut a new segment. + let total_segment_timescale_duration = self + .segment_state + .entry(self.redis_state.current_segment_idx()) + .or_insert_with(Default::default) + .0 + .fragments() + .iter() + .map(|fragment| fragment.duration) + .sum::(); + + // We need to see if the next samples can create a segment. + // To do this we need to iterate over the samples to find out if any of them are keyframes. + // If we have a keyframe we need to check if the samples before the keyframe can create a fragment. + let (sample_durations, segment_durations) = { + let mut total_sample_timescale_duration = 0; + self.tracks[0] + .samples + .iter() + .map(|(_, sample)| { + total_sample_timescale_duration += sample.duration; + ( + total_sample_timescale_duration as f64 / self.tracks[0].timescale as f64, + // We only calculate the segment duration if we have a keyframe. + // Since we cant cut a segment without a keyframe. + if sample.keyframe { + (total_sample_timescale_duration + total_segment_timescale_duration + - sample.duration) as f64 + / self.tracks[0].timescale as f64 + } else { + 0.0 + }, + ) + }) + .unzip::<_, _, Vec<_>, Vec<_>>() // unzip the tuples into two vectors. + }; + + let idx = segment_durations + .iter() + .position(|duration| *duration >= consts::SEGMENT_CUT_TARGET_DURATION); + + // If we have an index we can cut a new segment. + if let Some(idx) = idx { + let Some((segment, _)) = self.segment_state.get_mut(&self.redis_state.current_segment_idx()) else { + // This should never happen, but just in case. + return Err(anyhow!("failed to get current segment state")); + }; + + let last_sample_stream_idx = self.tracks[0].samples[idx].0; + let samples = self + .tracks + .iter_mut() + .map(|track| { + let upper_bound = track + .samples + .iter() + .enumerate() + .find_map(|(idx, (stream_idx, _))| { + if *stream_idx >= last_sample_stream_idx { + // We dont add 1 here because we want to include the last sample. + return Some(idx); + } + + None + }) + .unwrap_or_default(); + + track + .samples + .drain(..upper_bound) + .map(|(_, sample)| sample) + .collect::>() + }) + .collect::>(); + + segment.set_ready(true); + + if !samples[0].is_empty() { + self.create_fragment(samples)?; + } + + self.redis_state + .set_current_segment_idx(self.redis_state.current_segment_idx() + 1); + self.redis_state.set_current_fragment_idx(0); + + return Ok(()); + } + + // We want to find out if we have enough samples to create a fragment. + let Some(idx) = sample_durations.iter().enumerate().find_map(|(idx, duration)| { + if *duration >= consts::FRAGMENT_CUT_TARGET_DURATION && (*duration * 1000.0).fract() == 0.0 { + return Some(Some(idx)); + } + + if *duration >= consts::FRAGMENT_CUT_MAX_DURATION { + return Some(None); + } + + None + }) else { + // We dont have a place to create a fragment yet, so we need to wait a little bit. + return Ok(()); + }; + + let Some(idx) = idx.or_else(|| sample_durations.iter().position(|d| *d >= consts::FRAGMENT_CUT_TARGET_DURATION)) else { + // We dont have a place to create a fragment yet, so we need to wait a little bit. + return Ok(()); + }; + + let last_sample_stream_idx = self.tracks[0].samples[idx].0; + + // We now extract all the samples we need to create the next fragment. + let samples = self + .tracks + .iter_mut() + .map(|track| { + let upper_bound = track + .samples + .iter() + .position(|(stream_idx, _)| *stream_idx >= last_sample_stream_idx) + .map(|idx| idx + 1) + .unwrap_or_else(|| track.samples.len()); + + track + .samples + .drain(..upper_bound) + .map(|(_, sample)| sample) + .collect::>() + }) + .collect::>(); + + self.create_fragment(samples)?; + + Ok(()) + } + + fn create_fragment(&mut self, samples: Vec>) -> Result<()> { + // Get the current segment + let Some((segment, segment_data_state)) = self.segment_state.get_mut(&self.redis_state.current_segment_idx()) else { + return Err(anyhow!("failed to get current segment")); + }; + + let contains_keyframe = samples[0].iter().any(|sample| sample.keyframe); + segment.insert_fragment(state::Fragment { + duration: samples[0].iter().map(|sample| sample.duration).sum(), + keyframe: contains_keyframe, + }); + + let mut moof = Moof::new( + Mfhd::new(self.redis_state.sequence_number()), + samples + .iter() + .enumerate() + .map(|(idx, samples)| { + let mut traf = Traf::new( + Tfhd::new(idx as u32 + 1, None, None, None, None, None), + Some(Trun::new( + samples.iter().map(|s| s.sample.clone()).collect(), + None, + )), + Some(Tfdt::new(self.redis_state.track_duration(idx).unwrap())), + ); + + traf.optimize(); + + traf + }) + .collect(), + ); + + let moof_size = moof.size(); + + let track_sizes = samples + .iter() + .map(|s| s.iter().map(|s| s.data.len()).sum::()) + .collect::>(); + + moof.traf.iter_mut().enumerate().for_each(|(idx, traf)| { + let trun = traf.trun.as_mut().unwrap(); + + // The base is moof, so we offset by the moof, then we offset by the size of all the previous tracks + 8 bytes for the mdat header. + trun.data_offset = + Some(moof_size as i32 + track_sizes[..idx].iter().sum::() as i32 + 8); + }); + + let mdat = Mdat::new( + samples + .iter() + .flat_map(|s| s.iter().map(|s| s.data.clone())) + .collect(), + ); + + let mut writer = BytesWriter::default(); + moof.mux(&mut writer)?; + mdat.mux(&mut writer)?; + + segment_data_state.insert(self.redis_state.current_fragment_idx(), writer.dispose()); + + self.redis_state + .set_sequence_number(self.redis_state.sequence_number() + 1); + self.redis_state + .set_current_fragment_idx(self.redis_state.current_fragment_idx() + 1); + samples.iter().enumerate().for_each(|(idx, s)| { + self.redis_state.set_track_duration( + idx, + self.redis_state.track_duration(idx).unwrap() + + s.iter().map(|s| s.duration).sum::() as u64, + ); + }); + + Ok(()) + } + + async fn update_keys( + &mut self, + redis: &R, + ) -> Result { + self.generate_playlist() + .context("failed to generate playlist")?; + + let mut updated = false; + { + let mutations = self.redis_state.extract_mutations(); + if !mutations.is_empty() { + updated = true; + let _: RedisValue = redis + .hmset( + consts::redis_state_key(&self.stream_id, &self.variant_id), + mutations, + ) + .await + .context("failed to set redis state")?; + } + } + + for (idx, (segment, segment_data_state)) in self.segment_state.iter_mut() { + let mutations = segment.extract_mutations(); + if !mutations.is_empty() { + updated = true; + let _: RedisValue = redis + .hmset( + consts::redis_segment_state_key(&self.stream_id, &self.variant_id, *idx), + mutations, + ) + .await + .context("failed to set redis segment state")?; + } + + let data_state_mutations = std::mem::take(segment_data_state); + if !data_state_mutations.is_empty() { + updated = true; + let _: RedisValue = redis + .hmset( + consts::redis_segment_data_key(&self.stream_id, &self.variant_id, *idx), + data_state_mutations, + ) + .await + .context("failed to set redis segment data state")?; + } + } + + Ok(updated) + } + + async fn refresh_keys( + &mut self, + redis: &R, + ) -> Result<()> { + let mut keys = vec![ + consts::redis_state_key(&self.stream_id, &self.variant_id), + consts::redis_init_key(&self.stream_id, &self.variant_id), + ]; + + let lower_bound = (self.redis_state.current_segment_idx() as i32 - 4).max(0) as u32; + + keys.extend( + (lower_bound..self.redis_state.current_segment_idx() + 1).flat_map(|idx| { + [ + consts::redis_segment_state_key(&self.stream_id, &self.variant_id, idx), + consts::redis_segment_data_key(&self.stream_id, &self.variant_id, idx), + ] + }), + ); + + for key in keys.iter() { + let _: RedisValue = redis + .expire(key, consts::ACTIVE_EXPIRE_SECONDS) + .await + .context("failed to expire redis expire")?; + } + + for key in self + .segment_state + .keys() + .filter(|idx| **idx < lower_bound) + .copied() + .collect::>() + .into_iter() + .flat_map(|idx| { + self.segment_state.remove(&idx); + [ + consts::redis_segment_state_key(&self.stream_id, &self.variant_id, idx), + consts::redis_segment_data_key(&self.stream_id, &self.variant_id, idx), + ] + }) + { + let _: RedisValue = redis + .expire(key, consts::INACTIVE_EXPIRE_SECONDS) + .await + .context("failed to expire redis key")?; + } + + Ok(()) + } + + fn generate_playlist(&mut self) -> Result<()> { + let mut playlist = String::new(); + + let oldest_segment_idx = (self.redis_state.current_segment_idx() as i32 + - consts::ACTIVE_SEGMENT_COUNT as i32) + .max(0) as u32; + let oldest_fragment_display_idx = (self.redis_state.current_segment_idx() as i32 + - consts::ACTIVE_FRAGMENT_SEGMENT_COUNT as i32) + .max(0) as u32; + let newest_segment_idx = self.redis_state.current_segment_idx() + + if self.redis_state.current_fragment_idx() == 0 { + 0 + } else { + 1 + }; + + let mut discontinuity_sequence = self.redis_state.discontinuity_sequence() as i32; + let mut segment_data = String::new(); + + // According to spec this should never change. + // But we will just keep it changing to the largest fragment duration in this set of segments. + // However, this should be a good enough approximation. + // Baring that the framerate is not less than 8fps. (which is unlikely) + // If it is less than 8fps, then it will automatically increase to the longest fragment duration. + let mut longest_fragment_duration: f64 = consts::FRAGMENT_CUT_TARGET_DURATION; + + for idx in oldest_segment_idx..newest_segment_idx { + let Some((segment, _)) = self.segment_state.get(&idx) else { + return Err(anyhow::anyhow!("missing segment state: {}", idx)); + }; + + if segment.discontinuity() { + discontinuity_sequence -= 1; + segment_data.push_str("#EXT-X-DISCONTINUITY\n"); + } + + let track_1_timescale = self.redis_state.track_timescale(0).unwrap_or(1); + + let mut total_duration = 0; + for (f_idx, fragment) in segment.fragments().iter().enumerate() { + total_duration += fragment.duration; + + longest_fragment_duration = longest_fragment_duration + .max(fragment.duration as f64 / track_1_timescale as f64); + + if idx >= oldest_fragment_display_idx { + segment_data.push_str(&format!( + "#EXT-X-PART:DURATION={:.5},URI=\"{}.{}.mp4\"{}\n", + fragment.duration as f64 / track_1_timescale as f64, + idx, + f_idx, + if fragment.keyframe { + ",INDEPENDENT=YES" + } else { + "" + } + )); + } + } + + let segment_duration = total_duration as f64 / track_1_timescale as f64; + if segment_duration > self.redis_state.longest_segment() { + self.redis_state.set_longest_segment(segment_duration); + } + + if segment.ready() { + segment_data.push_str(&format!( + "#EXT-X-PROGRAM-DATE-TIME:{}\n", + segment + .timestamp() + .to_rfc3339_opts(SecondsFormat::Millis, true) + )); + + segment_data.push_str(&format!("#EXTINF:{:.5},\n", segment_duration)); + segment_data.push_str(&format!("{}.mp4\n", idx)); + } + } + + segment_data.push_str(&format!( + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{}.{}.mp4\"", + self.redis_state.current_segment_idx(), + self.redis_state.current_fragment_idx() + )); + + playlist.push_str("#EXTM3U\n"); + playlist.push_str(&format!( + "#EXT-X-TARGETDURATION:{}\n", + self.redis_state.longest_segment().ceil() as u32 * 2, + )); + playlist.push_str("#EXT-X-VERSION:9\n"); + playlist.push_str(&format!( + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={:.5}\n", + longest_fragment_duration * 2.0 + )); + playlist.push_str(&format!( + "#EXT-X-PART-INF:PART-TARGET={:.5}\n", + longest_fragment_duration + )); + playlist.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", oldest_segment_idx)); + playlist.push_str(&format!( + "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", + discontinuity_sequence.max(0) + )); + + playlist.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n"); + + playlist.push_str(&segment_data); + + self.redis_state.set_playlist(playlist); + + Ok(()) + } +} diff --git a/video/transcoder/src/transcoder/job/variant/state.rs b/video/transcoder/src/transcoder/job/variant/state.rs new file mode 100644 index 00000000..91ddf221 --- /dev/null +++ b/video/transcoder/src/transcoder/job/variant/state.rs @@ -0,0 +1,411 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone)] +pub struct PlaylistState { + mutations: HashMap, + current_segment_idx: u32, + current_fragment_idx: u32, + discontinuity_sequence: u32, + sequence_number: u32, + tracks: Vec, + playlist: String, + longest_segment: f64, +} + +impl Default for PlaylistState { + fn default() -> Self { + Self { + mutations: HashMap::from_iter(vec![ + ("current_segment_idx".to_string(), "0".to_string()), + ("current_fragment_idx".to_string(), "0".to_string()), + ("discontinuity_sequence".to_string(), "0".to_string()), + ("sequence_number".to_string(), "0".to_string()), + ("longest_segment".to_string(), "0.0".to_string()), + ("track_count".to_string(), "0".to_string()), + ("playlist".to_string(), String::new()), + ]), + current_segment_idx: 0, + current_fragment_idx: 0, + discontinuity_sequence: 0, + sequence_number: 0, + tracks: Vec::new(), + longest_segment: 0.0, + playlist: String::new(), + } + } +} + +impl PlaylistState { + pub fn set_current_segment_idx(&mut self, value: u32) { + if value != self.current_fragment_idx { + self.mutations + .insert("current_segment_idx".to_string(), value.to_string()); + self.current_segment_idx = value; + } + } + + pub fn set_current_fragment_idx(&mut self, value: u32) { + if value != self.current_fragment_idx { + self.mutations + .insert("current_fragment_idx".to_string(), value.to_string()); + self.current_fragment_idx = value; + } + } + + pub fn set_discontinuity_sequence(&mut self, value: u32) { + if value != self.discontinuity_sequence { + self.mutations + .insert("discontinuity_sequence".to_string(), value.to_string()); + self.discontinuity_sequence = value; + } + } + + pub fn set_sequence_number(&mut self, value: u32) { + if value != self.sequence_number { + self.mutations + .insert("sequence_number".to_string(), value.to_string()); + self.sequence_number = value; + } + } + + pub fn insert_track(&mut self, track: Track) { + self.mutations.insert( + format!("track_{}_duration", self.tracks.len() + 1), + track.duration.to_string(), + ); + self.mutations.insert( + format!("track_{}_timescale", self.tracks.len() + 1), + track.timescale.to_string(), + ); + self.mutations + .insert("track_count".into(), (self.tracks.len() + 1).to_string()); + + self.tracks.push(track); + } + + pub fn set_longest_segment(&mut self, value: f64) { + if value != self.longest_segment { + self.mutations + .insert("longest_segment".to_string(), value.to_string()); + self.longest_segment = value; + } + } + + pub fn set_track_duration(&mut self, track_idx: usize, value: u64) { + if let Some(track) = self.tracks.get_mut(track_idx) { + if value != track.duration { + self.mutations.insert( + format!("track_{}_duration", track_idx + 1), + value.to_string(), + ); + track.duration = value; + } + } + } + + pub fn set_playlist(&mut self, value: String) { + if value != self.playlist { + self.mutations.insert("playlist".to_string(), value.clone()); + self.playlist = value; + } + } + + #[inline(always)] + pub fn current_segment_idx(&self) -> u32 { + self.current_segment_idx + } + + #[inline(always)] + pub fn current_fragment_idx(&self) -> u32 { + self.current_fragment_idx + } + + #[inline(always)] + pub fn discontinuity_sequence(&self) -> u32 { + self.discontinuity_sequence + } + + #[inline(always)] + pub fn sequence_number(&self) -> u32 { + self.sequence_number + } + + #[inline(always)] + pub fn track_count(&self) -> usize { + self.tracks.len() + } + + #[inline(always)] + pub fn track_duration(&self, track_idx: usize) -> Option { + self.tracks.get(track_idx).map(|t| t.duration) + } + + #[inline(always)] + pub fn track_timescale(&self, track_idx: usize) -> Option { + self.tracks.get(track_idx).map(|t| t.timescale) + } + + #[inline(always)] + pub fn longest_segment(&self) -> f64 { + self.longest_segment + } + + pub fn extract_mutations(&mut self) -> HashMap { + std::mem::take(&mut self.mutations) + } +} + +#[derive(Debug, Clone)] +pub struct Track { + pub duration: u64, + pub timescale: u32, +} + +impl From> for PlaylistState { + fn from(value: HashMap) -> Self { + let mut mutations = HashMap::new(); + + let current_segment_idx = value + .get("current_segment_idx") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("current_segment_idx".to_string(), "0".to_string()); + 0 + }); + + let current_fragment_idx = value + .get("current_fragment_idx") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("current_fragment_idx".to_string(), "0".to_string()); + 0 + }); + + let discontinuity_sequence = value + .get("discontinuity_sequence") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("discontinuity_sequence".to_string(), "0".to_string()); + 0 + }); + + let track_count = value + .get("track_count") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("track_count".to_string(), "0".to_string()); + 0 + }); + + let sequence_number = value + .get("sequence_number") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("sequence_number".to_string(), "0".to_string()); + 0 + }); + + let playlist = value + .get("playlist") + .map(|v| v.to_string()) + .unwrap_or_else(|| { + mutations.insert("playlist".to_string(), "".to_string()); + "".to_string() + }); + + let mut tracks = Vec::with_capacity(track_count); + + for i in 0..track_count { + let duration = value + .get(&format!("track_{}_duration", i + 1)) + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert(format!("track_{}_duration", i + 1), "0".to_string()); + 0 + }); + + let timescale = value + .get(&format!("track_{}_timescale", i + 1)) + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert(format!("track_{}_timescale", i + 1), "0".to_string()); + 0 + }); + + tracks.push(Track { + duration, + timescale, + }); + } + + let longest_segment = value + .get("longest_segment") + .and_then(|v| v.parse::().ok()) + .unwrap_or_default(); + + Self { + mutations, + current_segment_idx, + current_fragment_idx, + discontinuity_sequence, + tracks, + longest_segment, + sequence_number, + playlist, + } + } +} + +#[derive(Debug, Clone)] +pub struct SegmentState { + mutations: HashMap, + ready: bool, + discontinuity: bool, + timestamp: DateTime, + fragments: Vec, +} + +#[derive(Debug, Clone)] +pub struct Fragment { + pub duration: u32, + pub keyframe: bool, +} + +impl Default for SegmentState { + fn default() -> Self { + Self { + mutations: HashMap::from_iter(vec![ + ("ready".into(), "false".into()), + ("discontinuity".into(), "false".into()), + ("timestamp".into(), Utc::now().to_rfc3339()), + ("fragment_count".into(), "0".into()), + ]), + ready: false, + discontinuity: false, + timestamp: Utc::now(), + fragments: Vec::new(), + } + } +} + +impl From> for SegmentState { + fn from(value: HashMap) -> Self { + let mut mutations = HashMap::new(); + + let ready = value + .get("ready") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("ready".into(), "false".into()); + false + }); + + let discontinuity = value + .get("discontinuity") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("discontinuity".into(), "false".into()); + false + }); + let timestamp = value + .get("timestamp") + .and_then(|v| { + DateTime::parse_from_rfc3339(v) + .map(|t| t.with_timezone(&Utc)) + .ok() + }) + .unwrap_or_else(|| { + let now = Utc::now(); + mutations.insert("timestamp".into(), now.to_rfc3339()); + now + }); + let fragment_count = value + .get("fragment_count") + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert("fragment_count".into(), "0".into()); + 0 + }); + + let mut fragments = Vec::with_capacity(fragment_count); + for i in 0..fragment_count { + let duration = value + .get(&format!("fragment_{}_duration", i)) + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert(format!("fragment_{}_duration", i), "0".into()); + 0 + }); + let keyframe = value + .get(&format!("fragment_{}_keyframe", i)) + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + mutations.insert(format!("fragment_{}_keyframe", i), "false".into()); + false + }); + fragments.push(Fragment { duration, keyframe }); + } + + Self { + mutations, + ready, + discontinuity, + timestamp, + fragments, + } + } +} + +impl SegmentState { + #[inline(always)] + pub fn ready(&self) -> bool { + self.ready + } + + #[inline(always)] + pub fn discontinuity(&self) -> bool { + self.discontinuity + } + + #[inline(always)] + pub fn timestamp(&self) -> DateTime { + self.timestamp + } + + #[inline(always)] + pub fn fragments(&self) -> &[Fragment] { + &self.fragments + } + + pub fn set_ready(&mut self, ready: bool) { + self.mutations.insert("ready".into(), ready.to_string()); + self.ready = ready; + } + + pub fn set_discontinuity(&mut self, discontinuity: bool) { + self.mutations + .insert("discontinuity".into(), discontinuity.to_string()); + self.discontinuity = discontinuity; + } + + pub fn insert_fragment(&mut self, fragment: Fragment) { + let idx = self.fragments.len(); + self.mutations.insert( + format!("fragment_{}_duration", idx), + fragment.duration.to_string(), + ); + self.mutations.insert( + format!("fragment_{}_keyframe", idx), + fragment.keyframe.to_string(), + ); + self.mutations + .insert("fragment_count".into(), (idx + 1).to_string()); + self.fragments.push(fragment); + } + + pub fn extract_mutations(&mut self) -> HashMap { + std::mem::take(&mut self.mutations) + } +} diff --git a/video/transcoder/src/transcoder/mod.rs b/video/transcoder/src/transcoder/mod.rs index bbbaf37d..099f0edf 100644 --- a/video/transcoder/src/transcoder/mod.rs +++ b/video/transcoder/src/transcoder/mod.rs @@ -1,9 +1,43 @@ -use std::sync::Arc; +use std::{pin::pin, sync::Arc}; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use futures::StreamExt; +use lapin::{options::BasicConsumeOptions, types::FieldTable}; +use tokio::select; +use tokio_util::sync::CancellationToken; -use crate::config::AppConfig; +use crate::{global::GlobalState, transcoder::job::handle_message}; -pub async fn run(_config: Arc) -> Result<()> { - todo!() +mod job; + +pub async fn run(global: Arc) -> Result<()> { + let mut consumer = pin!(global.rmq.basic_consume( + &global.config.rmq.transcoder_queue, + &global.config.name, + BasicConsumeOptions::default(), + FieldTable::default() + )); + + let shutdown_token = CancellationToken::new(); + let child_token = shutdown_token.child_token(); + let _drop_token = shutdown_token.drop_guard(); + + loop { + select! { + m = consumer.next() => { + let Some(m) = m else { + return Err(anyhow!("rmq stream closed")); + }; + + let m = m.map_err(|e| { + anyhow!("failed to get message: {}", e) + })?; + + tokio::spawn(handle_message(global.clone(), m, child_token.clone())); + }, + _ = global.ctx.done() => { + return Ok(()); + } + } + } } diff --git a/video/transmuxer/Cargo.toml b/video/transmuxer/Cargo.toml new file mode 100644 index 00000000..47e74016 --- /dev/null +++ b/video/transmuxer/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "transmuxer" +version = "0.0.1" +edition = "2021" + +[dependencies] +byteorder = "1" +bytes = "1" + +bytesio = { path = "../bytesio" } +h264 = { path = "../codec/h264" } +h265 = { path = "../codec/h265" } +av1 = { path = "../codec/av1" } +aac = { path = "../codec/aac" } +amf0 = { path = "../utils/amf0" } +flv = { path = "../container/flv" } +mp4 = { path = "../container/mp4" } + +[dev-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/video/transmuxer/src/codecs/aac.rs b/video/transmuxer/src/codecs/aac.rs new file mode 100644 index 00000000..f3aebefe --- /dev/null +++ b/video/transmuxer/src/codecs/aac.rs @@ -0,0 +1,90 @@ +use aac::AudioSpecificConfig; +use bytes::Bytes; +use flv::{SoundSize, SoundType}; +use mp4::{ + types::{ + esds::{ + descriptor::{ + header::DescriptorHeader, + traits::DescriptorType, + types::{ + decoder_config::DecoderConfigDescriptor, + decoder_specific_info::DecoderSpecificInfoDescriptor, es::EsDescriptor, + }, + }, + Esds, + }, + mp4a::Mp4a, + stsd::{AudioSampleEntry, SampleEntry}, + trun::{TrunSample, TrunSampleFlag}, + }, + DynBox, +}; + +use crate::TransmuxError; + +pub fn stsd_entry( + sound_size: SoundSize, + sound_type: SoundType, + data: Bytes, +) -> Result<(DynBox, AudioSpecificConfig), TransmuxError> { + let aac_config = aac::AudioSpecificConfig::parse(data)?; + + Ok(( + Mp4a::new( + SampleEntry::new(AudioSampleEntry::new( + match sound_type { + SoundType::Mono => 1, + SoundType::Stereo => 2, + }, + match sound_size { + SoundSize::Bit8 => 8, + SoundSize::Bit16 => 16, + }, + aac_config.sampling_frequency, + )), + Esds::new(EsDescriptor::new( + 2, + 0, + Some(0), + None, + Some(0), + Some(DecoderConfigDescriptor::new( + 0x40, // aac + 0x05, // audio stream + 0, // max bitrate + 0, // avg bitrate + Some(DecoderSpecificInfoDescriptor { + header: DescriptorHeader::new(DecoderSpecificInfoDescriptor::TAG), + data: aac_config.data.clone(), + }), + )), + None, + )), + None, + ) + .into(), + aac_config, + )) +} + +pub fn trun_sample(data: &Bytes) -> Result<(TrunSample, u32), TransmuxError> { + Ok(( + TrunSample { + duration: Some(1024), + composition_time_offset: None, + flags: Some(TrunSampleFlag { + reserved: 0, + is_leading: 0, + sample_degradation_priority: 0, + sample_depends_on: 2, + sample_has_redundancy: 0, + sample_is_depended_on: 0, + sample_is_non_sync_sample: false, + sample_padding_value: 0, + }), + size: Some(data.len() as u32), + }, + 1024, + )) +} diff --git a/video/transmuxer/src/codecs/av1.rs b/video/transmuxer/src/codecs/av1.rs new file mode 100644 index 00000000..30ecc1fc --- /dev/null +++ b/video/transmuxer/src/codecs/av1.rs @@ -0,0 +1,78 @@ +use av1::{seq::SequenceHeaderObu, AV1CodecConfigurationRecord, ObuHeader, ObuType}; +use bytes::Bytes; +use bytesio::bit_reader::BitReader; +use flv::FrameType; +use mp4::{ + types::{ + av01::Av01, + av1c::Av1C, + colr::{ColorType, Colr}, + stsd::{SampleEntry, VisualSampleEntry}, + trun::{TrunSample, TrunSampleFlag}, + }, + DynBox, +}; + +use crate::TransmuxError; + +pub fn stsd_entry( + config: AV1CodecConfigurationRecord, +) -> Result<(DynBox, SequenceHeaderObu), TransmuxError> { + let (header, data) = ObuHeader::parse(&mut BitReader::from(config.config_obu.clone()))?; + + if header.obu_type != ObuType::SequenceHeader { + return Err(TransmuxError::InvalidAv1DecoderConfigurationRecord); + } + + let seq_obu = SequenceHeaderObu::parse(header, data)?; + + // Unfortunate there does not seem to be a way to get the + // frame rate from the sequence header unless the timing_info is present + // Which it almost never is. + // So for AV1 we rely on the framerate being set in the scriptdata tag + + Ok(( + Av01::new( + SampleEntry::new(VisualSampleEntry::new( + seq_obu.max_frame_width as u16, + seq_obu.max_frame_height as u16, + Some(Colr::new(ColorType::Nclx { + color_primaries: seq_obu.color_config.color_primaries as u16, + matrix_coefficients: seq_obu.color_config.matrix_coefficients as u16, + transfer_characteristics: seq_obu.color_config.transfer_characteristics as u16, + full_range_flag: seq_obu.color_config.full_color_range, + })), + )), + Av1C::new(config), + None, + ) + .into(), + seq_obu, + )) +} + +pub fn trun_sample( + frame_type: FrameType, + duration: u32, + data: &Bytes, +) -> Result { + Ok(TrunSample { + composition_time_offset: None, + duration: Some(duration), + flags: Some(TrunSampleFlag { + reserved: 0, + is_leading: 0, + sample_degradation_priority: 0, + sample_depends_on: if frame_type == FrameType::Keyframe { + 2 + } else { + 1 + }, + sample_has_redundancy: 0, + sample_is_depended_on: 0, + sample_is_non_sync_sample: frame_type != FrameType::Keyframe, + sample_padding_value: 0, + }), + size: Some(data.len() as u32), + }) +} diff --git a/video/transmuxer/src/codecs/avc.rs b/video/transmuxer/src/codecs/avc.rs new file mode 100644 index 00000000..794e0df6 --- /dev/null +++ b/video/transmuxer/src/codecs/avc.rs @@ -0,0 +1,75 @@ +use bytes::Bytes; +use flv::FrameType; +use h264::{AVCDecoderConfigurationRecord, Sps}; +use mp4::{ + types::{ + avc1::Avc1, + avcc::AvcC, + colr::{ColorType, Colr}, + stsd::{SampleEntry, VisualSampleEntry}, + trun::{TrunSample, TrunSampleFlag}, + }, + DynBox, +}; + +use crate::TransmuxError; + +pub fn stsd_entry(config: AVCDecoderConfigurationRecord) -> Result<(DynBox, Sps), TransmuxError> { + if config.sps.is_empty() { + return Err(TransmuxError::InvalidAVCDecoderConfigurationRecord); + } + + let sps = h264::Sps::parse(config.sps[0].clone())?; + + let colr = sps.color_config.as_ref().map(|color_config| { + Colr::new(ColorType::Nclx { + color_primaries: color_config.color_primaries as u16, + matrix_coefficients: color_config.matrix_coefficients as u16, + transfer_characteristics: color_config.transfer_characteristics as u16, + full_range_flag: color_config.full_range, + }) + }); + + Ok(( + Avc1::new( + SampleEntry::new(VisualSampleEntry::new( + sps.width as u16, + sps.height as u16, + colr, + )), + AvcC::new(config), + None, + ) + .into(), + sps, + )) +} + +pub fn trun_sample( + frame_type: FrameType, + timestamp: u32, + last_video_timestamp: u32, + composition_time: u32, + duration: u32, + data: &Bytes, +) -> Result { + Ok(TrunSample { + composition_time_offset: Some((timestamp + composition_time - last_video_timestamp) as i64), + duration: Some(duration), + flags: Some(TrunSampleFlag { + reserved: 0, + is_leading: 0, + sample_degradation_priority: 0, + sample_depends_on: if frame_type == FrameType::Keyframe { + 2 + } else { + 1 + }, + sample_has_redundancy: 0, + sample_is_depended_on: 0, + sample_is_non_sync_sample: frame_type != FrameType::Keyframe, + sample_padding_value: 0, + }), + size: Some(data.len() as u32), + }) +} diff --git a/video/transmuxer/src/codecs/hevc.rs b/video/transmuxer/src/codecs/hevc.rs new file mode 100644 index 00000000..d2a7c9b4 --- /dev/null +++ b/video/transmuxer/src/codecs/hevc.rs @@ -0,0 +1,77 @@ +use bytes::Bytes; +use flv::FrameType; +use h265::{HEVCDecoderConfigurationRecord, Sps}; +use mp4::{ + types::{ + colr::{ColorType, Colr}, + hev1::Hev1, + hvcc::HvcC, + stsd::{SampleEntry, VisualSampleEntry}, + trun::{TrunSample, TrunSampleFlag}, + }, + DynBox, +}; + +use crate::TransmuxError; + +pub fn stsd_entry(config: HEVCDecoderConfigurationRecord) -> Result<(DynBox, Sps), TransmuxError> { + let Some(sps) = config.arrays.iter().find(|a| a.nal_unit_type == h265::NaluType::Sps).and_then(|v| v.nalus.get(0)) else { + return Err(TransmuxError::InvalidHEVCDecoderConfigurationRecord); + }; + + let sps = h265::Sps::parse(sps.clone())?; + + let colr = sps.color_config.as_ref().map(|color_config| { + Colr::new(ColorType::Nclx { + color_primaries: color_config.color_primaries as u16, + matrix_coefficients: color_config.matrix_coefficients as u16, + transfer_characteristics: color_config.transfer_characteristics as u16, + full_range_flag: color_config.full_range, + }) + }); + + Ok(( + Hev1::new( + SampleEntry::new(VisualSampleEntry::new( + sps.width as u16, + sps.height as u16, + colr, + )), + HvcC::new(config), + None, + ) + .into(), + sps, + )) +} + +pub fn trun_sample( + frame_type: FrameType, + timestamp: u32, + last_video_timestamp: u32, + composition_time: i32, + duration: u32, + data: &Bytes, +) -> Result { + Ok(TrunSample { + composition_time_offset: Some( + timestamp as i64 + composition_time as i64 - last_video_timestamp as i64, + ), + duration: Some(duration), + flags: Some(TrunSampleFlag { + reserved: 0, + is_leading: 0, + sample_degradation_priority: 0, + sample_depends_on: if frame_type == FrameType::Keyframe { + 2 + } else { + 1 + }, + sample_has_redundancy: 0, + sample_is_depended_on: 0, + sample_is_non_sync_sample: frame_type != FrameType::Keyframe, + sample_padding_value: 0, + }), + size: Some(data.len() as u32), + }) +} diff --git a/video/transmuxer/src/codecs/mod.rs b/video/transmuxer/src/codecs/mod.rs new file mode 100644 index 00000000..ed6a6d49 --- /dev/null +++ b/video/transmuxer/src/codecs/mod.rs @@ -0,0 +1,4 @@ +pub mod aac; +pub mod av1; +pub mod avc; +pub mod hevc; diff --git a/video/transmuxer/src/define.rs b/video/transmuxer/src/define.rs new file mode 100644 index 00000000..35fad0a6 --- /dev/null +++ b/video/transmuxer/src/define.rs @@ -0,0 +1,72 @@ +use av1::AV1CodecConfigurationRecord; +use bytes::Bytes; +use flv::{SoundSize, SoundType}; +use h264::AVCDecoderConfigurationRecord; +use h265::HEVCDecoderConfigurationRecord; +use mp4::codec::{AudioCodec, VideoCodec}; + +pub(crate) enum VideoSequenceHeader { + Avc(AVCDecoderConfigurationRecord), + Hevc(HEVCDecoderConfigurationRecord), + Av1(AV1CodecConfigurationRecord), +} + +pub(crate) struct AudioSequenceHeader { + pub sound_size: SoundSize, + pub sound_type: SoundType, + pub data: AudioSequenceHeaderData, +} + +pub(crate) enum AudioSequenceHeaderData { + Aac(Bytes), +} + +#[derive(Debug, Clone)] +pub enum TransmuxResult { + InitSegment { + video_settings: VideoSettings, + audio_settings: AudioSettings, + data: Bytes, + }, + MediaSegment(MediaSegment), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VideoSettings { + pub width: u32, + pub height: u32, + pub framerate: f64, + pub bitrate: u32, + pub codec: VideoCodec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AudioSettings { + pub sample_rate: u32, + pub channels: u8, + pub bitrate: u32, + pub codec: AudioCodec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaType { + Video, + Audio, +} + +#[derive(Debug, Clone)] +pub struct MediaSegment { + pub data: Bytes, + pub ty: MediaType, + pub keyframe: bool, + pub timestamp: u64, +} + +impl TransmuxResult { + pub fn into_bytes(self) -> Bytes { + match self { + TransmuxResult::InitSegment { data, .. } => data, + TransmuxResult::MediaSegment(data) => data.data, + } + } +} diff --git a/video/transmuxer/src/errors.rs b/video/transmuxer/src/errors.rs new file mode 100644 index 00000000..c0e72791 --- /dev/null +++ b/video/transmuxer/src/errors.rs @@ -0,0 +1,48 @@ +use std::io; + +#[derive(Debug)] +pub enum TransmuxError { + InvalidVideoDimensions, + InvalidVideoFrameRate, + InvalidAudioSampleRate, + InvalidHEVCDecoderConfigurationRecord, + InvalidAv1DecoderConfigurationRecord, + InvalidAVCDecoderConfigurationRecord, + NoSequenceHeaders, + IO(io::Error), + FlvDemuxer(flv::FlvDemuxerError), +} + +impl From for TransmuxError { + fn from(err: flv::FlvDemuxerError) -> Self { + Self::FlvDemuxer(err) + } +} + +impl From for TransmuxError { + fn from(err: io::Error) -> Self { + Self::IO(err) + } +} + +impl std::fmt::Display for TransmuxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidVideoDimensions => write!(f, "invalid video dimensions"), + Self::InvalidVideoFrameRate => write!(f, "invalid video frame rate"), + Self::InvalidAudioSampleRate => write!(f, "invalid audio sample rate"), + Self::InvalidHEVCDecoderConfigurationRecord => { + write!(f, "invalid hevc decoder configuration record") + } + Self::InvalidAv1DecoderConfigurationRecord => { + write!(f, "invalid av1 decoder configuration record") + } + Self::InvalidAVCDecoderConfigurationRecord => { + write!(f, "invalid avc decoder configuration record") + } + Self::NoSequenceHeaders => write!(f, "no sequence headers"), + Self::IO(err) => write!(f, "io error: {}", err), + Self::FlvDemuxer(err) => write!(f, "flv demuxer error: {}", err), + } + } +} diff --git a/video/transmuxer/src/lib.rs b/video/transmuxer/src/lib.rs new file mode 100644 index 00000000..b32e2052 --- /dev/null +++ b/video/transmuxer/src/lib.rs @@ -0,0 +1,622 @@ +#![allow(clippy::single_match)] + +use std::{ + collections::{HashMap, VecDeque}, + fmt::Debug, + io, +}; + +use amf0::Amf0Value; +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::{Buf, Bytes}; +use bytesio::bytes_writer::BytesWriter; +use flv::{ + AacPacket, Av1Packet, AvcPacket, EnhancedPacket, FlvTag, FlvTagAudioData, FlvTagData, + FlvTagVideoData, FrameType, HevcPacket, SoundType, +}; +use mp4::{ + codec::{AudioCodec, VideoCodec}, + types::{ + ftyp::{FourCC, Ftyp}, + hdlr::{HandlerType, Hdlr}, + mdat::Mdat, + mdhd::Mdhd, + mdia::Mdia, + mfhd::Mfhd, + minf::Minf, + moof::Moof, + moov::Moov, + mvex::Mvex, + mvhd::Mvhd, + smhd::Smhd, + stbl::Stbl, + stco::Stco, + stsc::Stsc, + stsd::Stsd, + stsz::Stsz, + stts::Stts, + tfdt::Tfdt, + tfhd::Tfhd, + tkhd::Tkhd, + traf::Traf, + trak::Trak, + trex::Trex, + trun::Trun, + vmhd::Vmhd, + }, + BoxType, +}; + +mod codecs; +mod define; +mod errors; + +pub use define::*; +pub use errors::TransmuxError; + +#[derive(Debug, Clone)] +pub struct Transmuxer { + // These durations are measured in timescales + /// sample_freq * 1000 + audio_duration: u64, + /// fps * 1000 + video_duration: u64, + sequence_number: u32, + last_video_timestamp: u32, + settings: Option<(VideoSettings, AudioSettings)>, + tags: VecDeque, +} + +impl Default for Transmuxer { + fn default() -> Self { + Self::new() + } +} + +impl Transmuxer { + pub fn new() -> Self { + Self { + sequence_number: 1, + tags: VecDeque::new(), + audio_duration: 0, + video_duration: 0, + last_video_timestamp: 0, + settings: None, + } + } + + /// Feed raw FLV data to the transmuxer. + pub fn demux(&mut self, data: Bytes) -> Result<(), TransmuxError> { + let mut cursor = io::Cursor::new(data); + while cursor.has_remaining() { + cursor.read_u32::()?; // previous tag size + if !cursor.has_remaining() { + break; + } + + let tag = flv::FlvTag::demux(&mut cursor)?; + self.tags.push_back(tag); + } + + Ok(()) + } + + /// Feed a single FLV tag to the transmuxer. + pub fn add_tag(&mut self, tag: FlvTag) { + self.tags.push_back(tag); + } + + /// Get the next transmuxed packet. This will return `None` if there is not enough data to create a packet. + pub fn mux(&mut self) -> Result, TransmuxError> { + let mut writer = BytesWriter::default(); + + let Some((video_settings, _)) = &self.settings else { + let Some((video_settings, audio_settings)) = self.init_sequence(&mut writer)? else { + if self.tags.len() > 30 { + // We are clearly not getting any sequence headers, so we should just give up + return Err(TransmuxError::NoSequenceHeaders); + } + + // We don't have enough tags to create an init segment yet + return Ok(None); + }; + + self.settings = Some((video_settings.clone(), audio_settings.clone())); + + let data = writer.dispose(); + + return Ok(Some(TransmuxResult::InitSegment { + data, + audio_settings, + video_settings, + })); + }; + + loop { + let Some(tag) = self.tags.pop_front() else { + return Ok(None); + }; + + let mdat_data; + let total_duration; + let trun_sample; + let mut is_audio = false; + let mut is_keyframe = false; + + let duration = if self.last_video_timestamp == 0 + || tag.timestamp == 0 + || tag.timestamp < self.last_video_timestamp + { + 1000 // the first frame is always 1000 ticks where the timescale is 1000 * fps. + } else { + // Since the delta is in milliseconds (ie 1/1000 of a second) + // Rounding errors happen. Our presision is only 1/1000 of a second. + // So if we have a 30fps video the delta should be 33.33ms (1000/30) + // But we can only represent this as 33ms or 34ms. So we will get rounding errors. + // To fix this we just check if the delta is 1 more or 1 less than the expected delta. + // And if it is we just use the expected delta. + // The reason we use a timescale which is 1000 * fps is because then we can always represent the delta as an integer. + // If we use a timescale of 1000, we would run into the same rounding errors. + let delta = tag.timestamp as f64 - self.last_video_timestamp as f64; + let expected_delta = 1000.0 / video_settings.framerate; + if (delta - expected_delta).abs() <= 1.0 { + 1000 + } else { + (delta * video_settings.framerate) as u32 + } + }; + + match tag.data { + FlvTagData::Audio { + data: FlvTagAudioData::Aac(AacPacket::Raw(data)), + .. + } => { + let (sample, duration) = codecs::aac::trun_sample(&data)?; + + trun_sample = sample; + mdat_data = data; + total_duration = duration; + is_audio = true; + } + FlvTagData::Video { + frame_type, + data: + FlvTagVideoData::Avc(AvcPacket::Nalu { + composition_time, + data, + }), + } => { + let sample = codecs::avc::trun_sample( + frame_type, + tag.timestamp, + self.last_video_timestamp, + composition_time, + duration, + &data, + )?; + + trun_sample = sample; + total_duration = duration; + mdat_data = data; + + is_keyframe = frame_type == FrameType::Keyframe; + } + FlvTagData::Video { + frame_type, + data: FlvTagVideoData::Enhanced(EnhancedPacket::Av1(Av1Packet::Raw(data))), + } => { + let sample = codecs::av1::trun_sample(frame_type, duration, &data)?; + + trun_sample = sample; + total_duration = duration; + mdat_data = data; + + is_keyframe = frame_type == FrameType::Keyframe; + } + FlvTagData::Video { + frame_type, + data: + FlvTagVideoData::Enhanced(EnhancedPacket::Hevc(HevcPacket::Nalu { + composition_time, + data, + })), + } => { + let sample = codecs::hevc::trun_sample( + frame_type, + tag.timestamp, + self.last_video_timestamp, + composition_time.unwrap_or_default(), + duration, + &data, + )?; + + trun_sample = sample; + total_duration = duration; + mdat_data = data; + + is_keyframe = frame_type == FrameType::Keyframe; + } + _ => { + // We don't support anything else + continue; + } + } + + let trafs = { + let (main_duration, second_duration, main_id, second_id) = if is_audio { + (self.audio_duration, self.video_duration, 2, 1) + } else { + (self.video_duration, self.audio_duration, 1, 2) + }; + + let mut first_traf = Traf::new( + Tfhd::new(main_id, None, None, None, None, None), + Some(Trun::new(vec![trun_sample], None)), + Some(Tfdt::new(main_duration)), + ); + first_traf.optimize(); + + let mut second_traf = Traf::new( + Tfhd::new(second_id, None, None, None, None, None), + Some(Trun::new(vec![], None)), + Some(Tfdt::new(second_duration)), + ); + second_traf.optimize(); + + vec![first_traf, second_traf] + }; + + let mut moof = Moof::new(Mfhd::new(self.sequence_number), trafs); + + // We need to get the moof size so that we can set the data offsets. + let moof_size = moof.size(); + + // We just created the moof, and therefore we know that the first traf is the video traf + // and the second traf is the audio traf. So we can just unwrap them and set the data offsets. + let traf = moof + .traf + .get_mut(0) + .expect("we just created the moof with a traf"); + + // Again we know that these exist because we just created it. + let trun = traf + .trun + .as_mut() + .expect("we just created the video traf with a trun"); + + // We now define the offsets. + // So the video offset will be the size of the moof + 8 bytes for the mdat header. + trun.data_offset = Some(moof_size as i32 + 8); + + // We then write the moof to the writer. + moof.mux(&mut writer)?; + + // We create an mdat box and write it to the writer. + Mdat::new(vec![mdat_data]).mux(&mut writer)?; + + // Increase our sequence number and duration. + self.sequence_number += 1; + + if is_audio { + self.audio_duration += total_duration as u64; + return Ok(Some(TransmuxResult::MediaSegment(MediaSegment { + data: writer.dispose(), + ty: MediaType::Audio, + keyframe: false, + timestamp: tag.timestamp as u64, + }))); + } else { + self.video_duration += total_duration as u64; + self.last_video_timestamp = tag.timestamp; + return Ok(Some(TransmuxResult::MediaSegment(MediaSegment { + data: writer.dispose(), + ty: MediaType::Video, + keyframe: is_keyframe, + timestamp: tag.timestamp as u64, + }))); + } + } + } + + /// Internal function to find the tags we need to create the init segment. + fn find_tags( + &self, + ) -> ( + Option, + Option, + Option>, + ) { + let tags = self.tags.iter(); + let mut video_sequence_header = None; + let mut audio_sequence_header = None; + let mut scriptdata_tag = None; + + for tag in tags { + if video_sequence_header.is_some() + && audio_sequence_header.is_some() + && scriptdata_tag.is_some() + { + break; + } + + match &tag.data { + FlvTagData::Video { + frame_type: _, + data: FlvTagVideoData::Avc(AvcPacket::SequenceHeader(data)), + } => { + video_sequence_header = Some(VideoSequenceHeader::Avc(data.clone())); + } + FlvTagData::Video { + frame_type: _, + data: + FlvTagVideoData::Enhanced(EnhancedPacket::Av1(Av1Packet::SequenceStart(config))), + } => { + video_sequence_header = Some(VideoSequenceHeader::Av1(config.clone())); + } + FlvTagData::Video { + frame_type: _, + data: + FlvTagVideoData::Enhanced(EnhancedPacket::Hevc(HevcPacket::SequenceStart( + config, + ))), + } => { + video_sequence_header = Some(VideoSequenceHeader::Hevc(config.clone())); + } + FlvTagData::Audio { + sound_size, + sound_type, + sound_rate: _, + data: FlvTagAudioData::Aac(AacPacket::SequenceHeader(data)), + } => { + audio_sequence_header = Some(AudioSequenceHeader { + data: AudioSequenceHeaderData::Aac(data.clone()), + sound_size: *sound_size, + sound_type: *sound_type, + }); + } + FlvTagData::ScriptData { data, name } => { + if name == "@setDataFrame" || name == "onMetaData" { + let meta_object = data.iter().find(|v| matches!(v, Amf0Value::Object(_))); + + if let Some(Amf0Value::Object(meta_object)) = meta_object { + scriptdata_tag = Some(meta_object.clone()); + } + } + } + _ => {} + } + } + + (video_sequence_header, audio_sequence_header, scriptdata_tag) + } + + /// Create the init segment. + fn init_sequence( + &mut self, + writer: &mut BytesWriter, + ) -> Result, TransmuxError> { + // We need to find the tag that is the video sequence header + // and the audio sequence header + let (video_sequence_header, audio_sequence_header, scriptdata_tag) = self.find_tags(); + + let Some(video_sequence_header) = video_sequence_header else { + return Ok(None); + }; + let Some(audio_sequence_header) = audio_sequence_header else { + return Ok(None); + }; + + let video_codec; + let audio_codec; + let video_width; + let video_height; + let audio_channels; + let audio_sample_rate; + let mut video_fps = 0.0; + + let mut estimated_video_bitrate = 0; + let mut estimated_audio_bitrate = 0; + + if let Some(scriptdata_tag) = scriptdata_tag { + video_fps = scriptdata_tag + .get("framerate") + .and_then(|v| match v { + Amf0Value::Number(v) => Some(*v), + _ => None, + }) + .unwrap_or(0.0); + + estimated_video_bitrate = scriptdata_tag + .get("videodatarate") + .and_then(|v| match v { + Amf0Value::Number(v) => Some((*v * 1024.0) as u32), + _ => None, + }) + .unwrap_or(0); + + estimated_audio_bitrate = scriptdata_tag + .get("audiodatarate") + .and_then(|v| match v { + Amf0Value::Number(v) => Some((*v * 1024.0) as u32), + _ => None, + }) + .unwrap_or(0); + } + + let mut compatiable_brands = vec![FourCC::Iso5, FourCC::Iso6]; + + let video_stsd_entry = match video_sequence_header { + VideoSequenceHeader::Avc(config) => { + compatiable_brands.push(FourCC::Avc1); + video_codec = VideoCodec::Avc { + constraint_set: config.profile_compatibility, + level: config.level_indication, + profile: config.profile_indication, + }; + + let (entry, sps) = codecs::avc::stsd_entry(config)?; + if sps.frame_rate != 0.0 { + video_fps = sps.frame_rate; + } + + video_width = sps.width as u32; + video_height = sps.height as u32; + + entry + } + VideoSequenceHeader::Av1(config) => { + compatiable_brands.push(FourCC::Av01); + let (entry, seq_obu) = codecs::av1::stsd_entry(config)?; + + video_height = seq_obu.max_frame_height as u32; + video_width = seq_obu.max_frame_width as u32; + + let op_point = &seq_obu.operating_points[0]; + + video_codec = VideoCodec::Av1 { + profile: seq_obu.seq_profile, + level: op_point.seq_level_idx, + tier: op_point.seq_tier, + depth: seq_obu.color_config.bit_depth as u8, + monochrome: seq_obu.color_config.mono_chrome, + sub_sampling_x: seq_obu.color_config.subsampling_x, + sub_sampling_y: seq_obu.color_config.subsampling_y, + color_primaries: seq_obu.color_config.color_primaries, + transfer_characteristics: seq_obu.color_config.transfer_characteristics, + matrix_coefficients: seq_obu.color_config.matrix_coefficients, + full_range_flag: seq_obu.color_config.full_color_range, + }; + + entry + } + VideoSequenceHeader::Hevc(config) => { + compatiable_brands.push(FourCC::Hev1); + video_codec = VideoCodec::Hevc { + constraint_indicator: config.general_constraint_indicator_flags, + level: config.general_level_idc, + profile: config.general_profile_idc, + profile_compatibility: config.general_profile_compatibility_flags, + tier: config.general_tier_flag, + general_profile_space: config.general_profile_space, + }; + + let (entry, sps) = codecs::hevc::stsd_entry(config)?; + if sps.frame_rate != 0.0 { + video_fps = sps.frame_rate; + } + + video_width = sps.width as u32; + video_height = sps.height as u32; + + entry + } + }; + + let audio_stsd_entry = match audio_sequence_header.data { + AudioSequenceHeaderData::Aac(data) => { + compatiable_brands.push(FourCC::Mp41); + let (entry, config) = codecs::aac::stsd_entry( + audio_sequence_header.sound_size, + audio_sequence_header.sound_type, + data, + )?; + + audio_sample_rate = config.sampling_frequency; + + audio_codec = AudioCodec::Aac { + object_type: config.audio_object_type, + }; + audio_channels = match audio_sequence_header.sound_type { + SoundType::Mono => 1, + SoundType::Stereo => 2, + }; + + entry + } + }; + + if video_fps == 0.0 { + return Err(TransmuxError::InvalidVideoFrameRate); + } + + if video_width == 0 || video_height == 0 { + return Err(TransmuxError::InvalidVideoDimensions); + } + + if audio_sample_rate == 0 { + return Err(TransmuxError::InvalidAudioSampleRate); + } + + // The reason we multiply the FPS by 1000 is to avoid rounding errors + // Consider If we had a video with a framerate of 30fps. That would imply each frame is 33.333333ms + // So we are limited to a u32 and therefore we could only represent 33.333333ms as 33ms. + // So this value is 30 * 1000 = 30000 timescale units per second, making each frame 1000 units long instead of 33ms long. + let video_timescale = (1000.0 * video_fps) as u32; + + Ftyp::new(FourCC::Iso5, 512, compatiable_brands).mux(writer)?; + Moov::new( + Mvhd::new(0, 0, 1000, 0, 1), + vec![ + Trak::new( + Tkhd::new(0, 0, 1, 0, Some((video_width, video_height))), + None, + Mdia::new( + Mdhd::new(0, 0, video_timescale, 0), + Hdlr::new(HandlerType::Vide, "VideoHandler".to_string()), + Minf::new( + Stbl::new( + Stsd::new(vec![video_stsd_entry]), + Stts::new(vec![]), + Stsc::new(vec![]), + Stco::new(vec![]), + Some(Stsz::new(0, vec![])), + ), + Some(Vmhd::new()), + None, + ), + ), + ), + Trak::new( + Tkhd::new(0, 0, 2, 0, None), + None, + Mdia::new( + Mdhd::new(0, 0, audio_sample_rate, 0), + Hdlr::new(HandlerType::Soun, "SoundHandler".to_string()), + Minf::new( + Stbl::new( + Stsd::new(vec![audio_stsd_entry]), + Stts::new(vec![]), + Stsc::new(vec![]), + Stco::new(vec![]), + Some(Stsz::new(0, vec![])), + ), + None, + Some(Smhd::new()), + ), + ), + ), + ], + Some(Mvex::new(vec![Trex::new(1), Trex::new(2)], None)), + ) + .mux(writer)?; + + Ok(Some(( + VideoSettings { + width: video_width, + height: video_height, + framerate: video_fps, + codec: video_codec, + bitrate: estimated_video_bitrate, + }, + AudioSettings { + codec: audio_codec, + sample_rate: audio_sample_rate, + channels: audio_channels, + bitrate: estimated_audio_bitrate, + }, + ))) + } +} + +#[cfg(test)] +mod tests; diff --git a/video/transmuxer/src/tests/mod.rs b/video/transmuxer/src/tests/mod.rs new file mode 100644 index 00000000..55f16dab --- /dev/null +++ b/video/transmuxer/src/tests/mod.rs @@ -0,0 +1,362 @@ +use std::{ + io::{self, Write}, + path::PathBuf, + process::{Command, Stdio}, +}; + +use aac::AudioObjectType; +use bytesio::bytes_writer::BytesWriter; +use flv::FlvHeader; +use mp4::codec::{AudioCodec, VideoCodec}; + +use crate::{ + define::{AudioSettings, VideoSettings}, + TransmuxResult, Transmuxer, +}; + +#[test] +fn test_transmuxer_avc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let data = std::fs::read(dir.join("avc_aac.flv").to_str().unwrap()).unwrap(); + + let mut transmuxer = Transmuxer::new(); + + // Read the flv header first + let mut cursor = io::Cursor::new(data.into()); + FlvHeader::demux(&mut cursor).unwrap(); + + let pos = cursor.position() as usize; + + let data = cursor.into_inner().slice(pos..); + + let mut writer = BytesWriter::default(); + + transmuxer.demux(data).unwrap(); + + while let Some(data) = transmuxer.mux().unwrap() { + match &data { + TransmuxResult::InitSegment { + video_settings, + audio_settings, + .. + } => { + assert_eq!( + video_settings, + &VideoSettings { + width: 3840, + height: 2160, + framerate: 60.0, + bitrate: 7358243, + codec: VideoCodec::Avc { + profile: 100, + level: 51, + constraint_set: 0, + } + } + ); + assert_eq!(video_settings.codec.to_string(), "avc1.640033"); + + assert_eq!( + audio_settings, + &AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 130127, + codec: AudioCodec::Aac { + object_type: AudioObjectType::AacLowComplexity, + } + } + ); + assert_eq!(audio_settings.codec.to_string(), "mp4a.40.2"); + } + _ => {} + } + writer.write_all(&data.into_bytes()).unwrap(); + } + + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + ffprobe + .stdin + .as_mut() + .unwrap() + .write_all(&writer.dispose()) + .expect("write to stdin"); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["duration"], "1.002667"); + assert_eq!(json["format"]["tags"]["major_brand"], "iso5"); + assert_eq!(json["format"]["tags"]["minor_version"], "512"); + assert_eq!( + json["format"]["tags"]["compatible_brands"], + "iso5iso6avc1mp41" + ); + + assert_eq!(json["streams"][0]["codec_name"], "h264"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["width"], 3840); + assert_eq!(json["streams"][0]["height"], 2160); + assert_eq!(json["streams"][0]["r_frame_rate"], "60/1"); + assert_eq!(json["streams"][0]["avg_frame_rate"], "60/1"); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "48000"); + assert_eq!(json["streams"][1]["channels"], 2); +} + +#[test] +fn test_transmuxer_av1_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let data = std::fs::read(dir.join("av1_aac.flv").to_str().unwrap()).unwrap(); + + let mut transmuxer = Transmuxer::new(); + + // Read the flv header first + let mut cursor = io::Cursor::new(data.into()); + FlvHeader::demux(&mut cursor).unwrap(); + + let pos = cursor.position() as usize; + + let data = cursor.into_inner().slice(pos..); + + let mut writer = BytesWriter::default(); + + transmuxer.demux(data).unwrap(); + + while let Some(data) = transmuxer.mux().unwrap() { + match &data { + TransmuxResult::InitSegment { + video_settings, + audio_settings, + .. + } => { + assert_eq!( + video_settings, + &VideoSettings { + width: 2560, + height: 1440, + framerate: 144.0, + bitrate: 2560000, + codec: VideoCodec::Av1 { + profile: 0, + level: 13, + tier: false, + depth: 8, + sub_sampling_x: true, + sub_sampling_y: true, + monochrome: false, + full_range_flag: false, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + } + } + ); + assert_eq!( + video_settings.codec.to_string(), + "av01.0.13M.08.0.110.01.01.01.0" + ); + + assert_eq!( + audio_settings, + &AudioSettings { + sample_rate: 48000, + bitrate: 163840, + channels: 2, + codec: AudioCodec::Aac { + object_type: AudioObjectType::AacLowComplexity, + } + } + ); + assert_eq!(audio_settings.codec.to_string(), "mp4a.40.2"); + } + _ => {} + } + + writer.write_all(&data.into_bytes()).unwrap(); + } + + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + ffprobe + .stdin + .as_mut() + .unwrap() + .write_all(&writer.dispose()) + .unwrap(); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["tags"]["major_brand"], "iso5"); + assert_eq!(json["format"]["tags"]["minor_version"], "512"); + assert_eq!(json["format"]["duration"], "2.816000"); + assert_eq!( + json["format"]["tags"]["compatible_brands"], + "iso5iso6av01mp41" + ); + + assert_eq!(json["streams"][0]["codec_name"], "av1"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["width"], 2560); + assert_eq!(json["streams"][0]["height"], 1440); + assert_eq!(json["streams"][0]["r_frame_rate"], "144/1"); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "48000"); + assert_eq!(json["streams"][1]["channels"], 2); +} + +#[test] +fn test_transmuxer_hevc_aac() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets"); + let data = std::fs::read(dir.join("hevc_aac.flv").to_str().unwrap()).unwrap(); + + let mut transmuxer = Transmuxer::new(); + + // Read the flv header first + let mut cursor = io::Cursor::new(data.into()); + FlvHeader::demux(&mut cursor).unwrap(); + + let pos = cursor.position() as usize; + + let data = cursor.into_inner().slice(pos..); + + let mut writer = BytesWriter::default(); + + transmuxer.demux(data).unwrap(); + + while let Some(data) = transmuxer.mux().unwrap() { + match &data { + TransmuxResult::InitSegment { + video_settings, + audio_settings, + .. + } => { + assert_eq!( + video_settings, + &VideoSettings { + width: 2560, + height: 1440, + framerate: 144.0, + bitrate: 2560000, + codec: VideoCodec::Hevc { + general_profile_space: 0, + profile_compatibility: 64, + profile: 1, + level: 153, + tier: false, + constraint_indicator: 144, + } + } + ); + assert_eq!(video_settings.codec.to_string(), "hev1.1.40.L99.90"); + + assert_eq!( + audio_settings, + &AudioSettings { + sample_rate: 48000, + channels: 2, + bitrate: 163840, + codec: AudioCodec::Aac { + object_type: AudioObjectType::AacLowComplexity, + } + } + ); + assert_eq!(audio_settings.codec.to_string(), "mp4a.40.2"); + } + _ => {} + } + + writer.write_all(&data.into_bytes()).unwrap(); + } + + let mut ffprobe = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-fpsprobesize") + .arg("20000") + .arg("-show_format") + .arg("-show_streams") + .arg("-print_format") + .arg("json") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + ffprobe + .stdin + .as_mut() + .unwrap() + .write_all(&writer.dispose()) + .expect("write to stdin"); + + let output = ffprobe.wait_with_output().unwrap(); + assert!(output.status.success()); + + // Check the output is valid. + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(json["format"]["format_name"], "mov,mp4,m4a,3gp,3g2,mj2"); + assert_eq!(json["format"]["duration"], "3.083423"); + assert_eq!(json["format"]["tags"]["major_brand"], "iso5"); + assert_eq!(json["format"]["tags"]["minor_version"], "512"); + assert_eq!( + json["format"]["tags"]["compatible_brands"], + "iso5iso6hev1mp41" + ); + + assert_eq!(json["streams"][0]["codec_name"], "hevc"); + assert_eq!(json["streams"][0]["codec_type"], "video"); + assert_eq!(json["streams"][0]["width"], 2560); + assert_eq!(json["streams"][0]["height"], 1440); + assert_eq!(json["streams"][0]["r_frame_rate"], "144/1"); + + assert_eq!(json["streams"][1]["codec_name"], "aac"); + assert_eq!(json["streams"][1]["codec_type"], "audio"); + assert_eq!(json["streams"][1]["sample_rate"], "48000"); + assert_eq!(json["streams"][1]["channels"], 2); +} diff --git a/video/utils/amf0/Cargo.toml b/video/utils/amf0/Cargo.toml new file mode 100644 index 00000000..556abaf0 --- /dev/null +++ b/video/utils/amf0/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "amf0" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +byteorder = "1" +num-traits = "0" +num-derive = "0" +bytesio = { path = "../../bytesio", default-features = false } diff --git a/video/utils/amf0/src/define.rs b/video/utils/amf0/src/define.rs new file mode 100644 index 00000000..32aae52c --- /dev/null +++ b/video/utils/amf0/src/define.rs @@ -0,0 +1,45 @@ +use num_derive::FromPrimitive; +use std::collections::HashMap; + +/// AMF0 marker types. +/// Defined in amf0_spec_121207.pdf section 2.1 +#[derive(Debug, PartialEq, Eq, Clone, Copy, FromPrimitive)] +#[repr(u8)] +pub enum Amf0Marker { + Number = 0x00, + Boolean = 0x01, + String = 0x02, + Object = 0x03, + MovieClipMarker = 0x04, // reserved, not supported + Null = 0x05, + Undefined = 0x06, + Reference = 0x07, + EcmaArray = 0x08, + ObjectEnd = 0x09, + StrictArray = 0x0a, + Date = 0x0b, + LongString = 0x0c, + Unsupported = 0x0d, + Recordset = 0x0e, // reserved, not supported + XmlDocument = 0x0f, + TypedObject = 0x10, + AVMPlusObject = 0x11, // AMF3 marker +} + +#[derive(PartialEq, Clone, Debug)] +pub enum Amf0Value { + /// Number Type defined section 2.2 + Number(f64), + /// Boolean Type defined section 2.3 + Boolean(bool), + /// String Type defined section 2.4 + String(String), + /// Object Type defined section 2.5 + Object(HashMap), + /// Null Type defined section 2.7 + Null, + /// Undefined Type defined section 2.8 + ObjectEnd, + /// LongString Type defined section 2.14 + LongString(String), +} diff --git a/video/utils/amf0/src/errors.rs b/video/utils/amf0/src/errors.rs new file mode 100644 index 00000000..28a03aa0 --- /dev/null +++ b/video/utils/amf0/src/errors.rs @@ -0,0 +1,64 @@ +use std::{fmt, io, str}; + +use super::{define::Amf0Marker, Amf0Value}; + +#[derive(Debug)] +pub enum Amf0ReadError { + UnknownMarker(u8), + UnsupportedType(Amf0Marker), + StringParseError(str::Utf8Error), + IO(io::Error), + WrongType, +} + +macro_rules! from_error { + ($tt:ty, $val:expr, $err:ty) => { + impl From<$err> for $tt { + fn from(error: $err) -> Self { + $val(error) + } + } + }; +} + +from_error!(Amf0ReadError, Self::StringParseError, str::Utf8Error); +from_error!(Amf0ReadError, Self::IO, io::Error); + +#[derive(Debug)] +pub enum Amf0WriteError { + NormalStringTooLong, + IO(io::Error), + UnsupportedType(Amf0Value), +} + +from_error!(Amf0WriteError, Self::IO, io::Error); + +impl fmt::Display for Amf0ReadError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::UnknownMarker(marker) => { + write!(f, "unknown marker: {}", marker) + } + Self::UnsupportedType(marker) => { + write!(f, "unsupported type: {:?}", marker) + } + Self::WrongType => write!(f, "wrong type"), + Self::StringParseError(err) => write!(f, "string parse error: {}", err), + Self::IO(err) => write!(f, "io error: {}", err), + } + } +} + +impl fmt::Display for Amf0WriteError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::NormalStringTooLong => { + write!(f, "normal string too long") + } + Self::UnsupportedType(value_type) => { + write!(f, "unsupported type: {:?}", value_type) + } + Self::IO(error) => write!(f, "io error: {}", error), + } + } +} diff --git a/video/utils/amf0/src/lib.rs b/video/utils/amf0/src/lib.rs new file mode 100644 index 00000000..e5e395b7 --- /dev/null +++ b/video/utils/amf0/src/lib.rs @@ -0,0 +1,14 @@ +mod define; +mod errors; +mod reader; +mod writer; + +pub use crate::{ + define::{Amf0Marker, Amf0Value}, + errors::{Amf0ReadError, Amf0WriteError}, + reader::Amf0Reader, + writer::Amf0Writer, +}; + +#[cfg(test)] +mod tests; diff --git a/video/utils/amf0/src/reader.rs b/video/utils/amf0/src/reader.rs new file mode 100644 index 00000000..3b56b169 --- /dev/null +++ b/video/utils/amf0/src/reader.rs @@ -0,0 +1,181 @@ +use super::{Amf0Marker, Amf0ReadError, Amf0Value}; +use byteorder::{BigEndian, ReadBytesExt}; +use bytes::Bytes; +use num_traits::FromPrimitive; +use std::{ + collections::HashMap, + io::{Cursor, Seek, SeekFrom}, +}; + +pub struct Amf0Reader { + cursor: Cursor, +} + +impl Amf0Reader { + pub fn new(buff: Bytes) -> Self { + Self { + cursor: Cursor::new(buff), + } + } + + fn is_empty(&self) -> bool { + self.cursor.get_ref().len() == self.cursor.position() as usize + } + + fn read_bytes(&mut self, len: usize) -> Result { + let pos = self.cursor.position(); + self.cursor.seek(SeekFrom::Current(len as i64))?; + Ok(self + .cursor + .get_ref() + .slice(pos as usize..pos as usize + len)) + } + + pub fn read_all(&mut self) -> Result, Amf0ReadError> { + let mut results = vec![]; + + loop { + let result = self.read_any()?; + + match result { + Amf0Value::ObjectEnd => { + break; + } + _ => { + results.push(result); + } + } + } + + Ok(results) + } + + pub fn read_any(&mut self) -> Result { + if self.is_empty() { + return Ok(Amf0Value::ObjectEnd); + } + + let marker = self.cursor.read_u8()?; + let marker = + Amf0Marker::from_u8(marker).ok_or_else(|| Amf0ReadError::UnknownMarker(marker))?; + + match marker { + Amf0Marker::Number => self.read_number(), + Amf0Marker::Boolean => self.read_bool(), + Amf0Marker::String => self.read_string(), + Amf0Marker::Object => self.read_object(), + Amf0Marker::Null => self.read_null(), + Amf0Marker::EcmaArray => self.read_ecma_array(), + Amf0Marker::LongString => self.read_long_string(), + _ => Err(Amf0ReadError::UnsupportedType(marker)), + } + } + + pub fn read_with_type( + &mut self, + specified_marker: Amf0Marker, + ) -> Result { + let marker = self.cursor.read_u8()?; + self.cursor.seek(SeekFrom::Current(-1))?; // seek back to the original position + + let marker = + Amf0Marker::from_u8(marker).ok_or_else(|| Amf0ReadError::UnknownMarker(marker))?; + if marker != specified_marker { + return Err(Amf0ReadError::WrongType); + } + + self.read_any() + } + + pub fn read_number(&mut self) -> Result { + let number = self.cursor.read_f64::()?; + let value = Amf0Value::Number(number); + Ok(value) + } + + pub fn read_bool(&mut self) -> Result { + let value = self.cursor.read_u8()?; + + match value { + 1 => Ok(Amf0Value::Boolean(true)), + _ => Ok(Amf0Value::Boolean(false)), + } + } + + fn read_raw_string(&mut self) -> Result { + let l = self.cursor.read_u16::()?; + + let bytes = self.read_bytes(l as usize)?; + + Ok(std::str::from_utf8(&bytes)?.to_string()) + } + + pub fn read_string(&mut self) -> Result { + let raw_string = self.read_raw_string()?; + Ok(Amf0Value::String(raw_string)) + } + + pub fn read_null(&mut self) -> Result { + Ok(Amf0Value::Null) + } + + pub fn is_read_object_eof(&mut self) -> Result { + let pos = self.cursor.position(); + let marker = self.cursor.read_u24::(); + self.cursor.seek(SeekFrom::Start(pos))?; + + match Amf0Marker::from_u32(marker?) { + Some(Amf0Marker::ObjectEnd) => { + self.cursor.read_u24::()?; + Ok(true) + } + _ => Ok(false), + } + } + + pub fn read_object(&mut self) -> Result { + let mut properties = HashMap::new(); + + loop { + let is_eof = self.is_read_object_eof()?; + + if is_eof { + break; + } + + let key = self.read_raw_string()?; + let val = self.read_any()?; + + properties.insert(key, val); + } + + Ok(Amf0Value::Object(properties)) + } + + pub fn read_ecma_array(&mut self) -> Result { + let len = self.cursor.read_u32::()?; + + let mut properties = HashMap::new(); + + for _ in 0..len { + let key = self.read_raw_string()?; + let val = self.read_any()?; + properties.insert(key, val); + } + + // Sometimes the object end marker is present and sometimes it is not. + // If it is there just read it, if not then we are done. + self.is_read_object_eof().ok(); // ignore the result + + Ok(Amf0Value::Object(properties)) + } + + pub fn read_long_string(&mut self) -> Result { + let l = self.cursor.read_u32::()?; + + let buff = self.read_bytes(l as usize)?; + let val = std::str::from_utf8(&buff)?; + + Ok(Amf0Value::LongString(val.to_string())) + } +} diff --git a/video/utils/amf0/src/tests.rs b/video/utils/amf0/src/tests.rs new file mode 100644 index 00000000..4f20f951 --- /dev/null +++ b/video/utils/amf0/src/tests.rs @@ -0,0 +1,209 @@ +use std::{collections::HashMap, io::Cursor}; + +use byteorder::ReadBytesExt; +use bytesio::bytes_writer::BytesWriter; + +use crate::{Amf0Marker, Amf0ReadError, Amf0Reader, Amf0Value, Amf0WriteError, Amf0Writer}; + +#[test] +fn test_reader_bool() { + let amf0_bool = vec![0x01, 0x01]; // true + let mut amf_reader = Amf0Reader::new(amf0_bool.into()); + let value = amf_reader.read_with_type(Amf0Marker::Boolean).unwrap(); + assert_eq!(value, Amf0Value::Boolean(true)); +} + +#[test] +fn test_reader_number() { + let mut amf0_number = vec![0x00]; + amf0_number.extend_from_slice(&772.161_f64.to_be_bytes()); + + let mut amf_reader = Amf0Reader::new(amf0_number.into()); + let value = amf_reader.read_with_type(Amf0Marker::Number).unwrap(); + assert_eq!(value, Amf0Value::Number(772.161)); +} + +#[test] +fn test_reader_string() { + let mut amf0_string = vec![0x02, 0x00, 0x0b]; // 11 bytes + amf0_string.extend_from_slice(b"Hello World"); + + let mut amf_reader = Amf0Reader::new(amf0_string.into()); + let value = amf_reader.read_with_type(Amf0Marker::String).unwrap(); + assert_eq!(value, Amf0Value::String("Hello World".to_string())); +} + +#[test] +fn test_reader_long_string() { + let mut amf0_string = vec![0x0c, 0x00, 0x00, 0x00, 0x0b]; // 11 bytes + amf0_string.extend_from_slice(b"Hello World"); + + let mut amf_reader = Amf0Reader::new(amf0_string.into()); + let value = amf_reader.read_with_type(Amf0Marker::LongString).unwrap(); + assert_eq!(value, Amf0Value::LongString("Hello World".to_string())); +} + +#[test] +fn test_reader_object() { + let mut amf0_object = vec![0x03, 0x00, 0x04]; // 1 property with 4 bytes + amf0_object.extend_from_slice(b"test"); + amf0_object.extend_from_slice(&[0x05]); // null + amf0_object.extend_from_slice(&[0x00, 0x00, 0x09]); // object end (0x00 0x00 0x09) + + let mut amf_reader = Amf0Reader::new(amf0_object.into()); + let value = amf_reader.read_with_type(Amf0Marker::Object).unwrap(); + + assert_eq!( + value, + Amf0Value::Object(HashMap::from([("test".to_string(), Amf0Value::Null)])) + ); +} + +#[test] +fn test_reader_ecma_array() { + let mut amf0_object = vec![0x08, 0x00, 0x00, 0x00, 0x01]; // 1 property + amf0_object.extend_from_slice(&[0x00, 0x04]); // 4 bytes + amf0_object.extend_from_slice(b"test"); + amf0_object.extend_from_slice(&[0x05]); // null + + let mut amf_reader = Amf0Reader::new(amf0_object.into()); + let value = amf_reader.read_with_type(Amf0Marker::EcmaArray).unwrap(); + + assert_eq!( + value, + Amf0Value::Object(HashMap::from([("test".to_string(), Amf0Value::Null)])) + ); +} + +#[test] +fn test_reader_multi_value() { + let mut amf0_multi = vec![0x00]; + amf0_multi.extend_from_slice(&772.161_f64.to_be_bytes()); + amf0_multi.extend_from_slice(&[0x01, 0x01]); // true + amf0_multi.extend_from_slice(&[0x02, 0x00, 0x0b]); // 11 bytes + amf0_multi.extend_from_slice(b"Hello World"); + amf0_multi.extend_from_slice(&[0x03, 0x00, 0x04]); // 1 property with 4 bytes + amf0_multi.extend_from_slice(b"test"); + amf0_multi.extend_from_slice(&[0x05]); // null + amf0_multi.extend_from_slice(&[0x00, 0x00, 0x09]); // object end (0x00 0x00 0x09) + + let mut amf_reader = Amf0Reader::new(amf0_multi.into()); + let values = amf_reader.read_all().unwrap(); + + assert_eq!(values.len(), 4); + + assert_eq!(values[0], Amf0Value::Number(772.161)); + assert_eq!(values[1], Amf0Value::Boolean(true)); + assert_eq!(values[2], Amf0Value::String("Hello World".to_string())); + assert_eq!( + values[3], + Amf0Value::Object(HashMap::from([("test".to_string(), Amf0Value::Null)])) + ); +} + +#[test] +fn test_read_error_display() { + assert_eq!( + Amf0ReadError::UnknownMarker(100).to_string(), + "unknown marker: 100" + ); + + assert_eq!( + Amf0ReadError::UnsupportedType(Amf0Marker::Reference).to_string(), + "unsupported type: Reference" + ); + + assert_eq!(Amf0ReadError::WrongType.to_string(), "wrong type"); + + assert_eq!( + Amf0ReadError::StringParseError(std::str::from_utf8(b"\xFF\xFF").unwrap_err()).to_string(), + "string parse error: invalid utf-8 sequence of 1 bytes from index 0" + ); + + assert_eq!( + Amf0ReadError::IO(Cursor::new(Vec::::new()).read_u8().unwrap_err()).to_string(), + "io error: failed to fill whole buffer" + ); +} + +#[test] +fn test_write_error_display() { + assert_eq!( + Amf0WriteError::UnsupportedType(Amf0Value::ObjectEnd).to_string(), + "unsupported type: ObjectEnd" + ); + + assert_eq!( + Amf0WriteError::IO(Cursor::new(Vec::::new()).read_u8().unwrap_err()).to_string(), + "io error: failed to fill whole buffer" + ); + + assert_eq!( + Amf0WriteError::NormalStringTooLong.to_string(), + "normal string too long" + ); +} + +#[test] +fn test_write_number() { + let mut amf0_number = vec![0x00]; + amf0_number.extend_from_slice(&772.161_f64.to_be_bytes()); + + let mut writer = BytesWriter::default(); + + Amf0Writer::write_number(&mut writer, 772.161).unwrap(); + + assert_eq!(writer.dispose(), amf0_number); +} + +#[test] +fn test_write_boolean() { + let amf0_boolean = vec![0x01, 0x01]; + + let mut writer = BytesWriter::default(); + + Amf0Writer::write_bool(&mut writer, true).unwrap(); + + assert_eq!(writer.dispose(), amf0_boolean); +} + +#[test] +fn test_write_string() { + let mut amf0_string = vec![0x02, 0x00, 0x0b]; + amf0_string.extend_from_slice(b"Hello World"); + + let mut writer = BytesWriter::default(); + + Amf0Writer::write_string(&mut writer, "Hello World").unwrap(); + + assert_eq!(writer.dispose(), amf0_string); +} + +#[test] +fn test_write_null() { + let amf0_null = vec![0x05]; + + let mut writer = BytesWriter::default(); + + Amf0Writer::write_null(&mut writer).unwrap(); + + assert_eq!(writer.dispose(), amf0_null); +} + +#[test] +fn test_write_object() { + let mut amf0_object = vec![0x03, 0x00, 0x04]; + amf0_object.extend_from_slice(b"test"); + amf0_object.extend_from_slice(&[0x05]); + amf0_object.extend_from_slice(&[0x00, 0x00, 0x09]); + + let mut writer = BytesWriter::default(); + + Amf0Writer::write_object( + &mut writer, + &HashMap::from([("test".to_string(), Amf0Value::Null)]), + ) + .unwrap(); + + assert_eq!(writer.dispose(), amf0_object); +} diff --git a/video/utils/amf0/src/writer.rs b/video/utils/amf0/src/writer.rs new file mode 100644 index 00000000..5b3550a5 --- /dev/null +++ b/video/utils/amf0/src/writer.rs @@ -0,0 +1,67 @@ +use super::{define::Amf0Marker, Amf0Value, Amf0WriteError}; +use byteorder::{BigEndian, WriteBytesExt}; +use bytesio::bytes_writer::BytesWriter; +use std::collections::HashMap; +use std::io::Write; + +pub struct Amf0Writer; + +impl Amf0Writer { + pub fn write_any(writer: &mut BytesWriter, value: &Amf0Value) -> Result<(), Amf0WriteError> { + match value { + Amf0Value::Boolean(val) => Self::write_bool(writer, *val), + Amf0Value::Null => Self::write_null(writer), + Amf0Value::Number(val) => Self::write_number(writer, *val), + Amf0Value::String(val) => Self::write_string(writer, val.as_str()), + Amf0Value::Object(val) => Self::write_object(writer, val), + _ => Err(Amf0WriteError::UnsupportedType(value.clone())), + } + } + + fn write_object_eof(writer: &mut BytesWriter) -> Result<(), Amf0WriteError> { + writer.write_u24::(Amf0Marker::ObjectEnd as u32)?; + Ok(()) + } + + pub fn write_number(writer: &mut BytesWriter, value: f64) -> Result<(), Amf0WriteError> { + writer.write_u8(Amf0Marker::Number as u8)?; + writer.write_f64::(value)?; + Ok(()) + } + + pub fn write_bool(writer: &mut BytesWriter, value: bool) -> Result<(), Amf0WriteError> { + writer.write_u8(Amf0Marker::Boolean as u8)?; + writer.write_u8(value as u8)?; + Ok(()) + } + + pub fn write_string(writer: &mut BytesWriter, value: &str) -> Result<(), Amf0WriteError> { + if value.len() > (u16::max_value() as usize) { + return Err(Amf0WriteError::NormalStringTooLong); + } + writer.write_u8(Amf0Marker::String as u8)?; + writer.write_u16::(value.len() as u16)?; + writer.write_all(value.as_bytes())?; + Ok(()) + } + + pub fn write_null(writer: &mut BytesWriter) -> Result<(), Amf0WriteError> { + writer.write_u8(Amf0Marker::Null as u8)?; + Ok(()) + } + + pub fn write_object( + writer: &mut BytesWriter, + properties: &HashMap, + ) -> Result<(), Amf0WriteError> { + writer.write_u8(Amf0Marker::Object as u8)?; + for (key, value) in properties { + writer.write_u16::(key.len() as u16)?; + writer.write_all(key.as_bytes())?; + Self::write_any(writer, value)?; + } + + Self::write_object_eof(writer)?; + Ok(()) + } +} diff --git a/video/utils/exp_golomb/Cargo.toml b/video/utils/exp_golomb/Cargo.toml new file mode 100644 index 00000000..dbf6dede --- /dev/null +++ b/video/utils/exp_golomb/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "exp_golomb" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes = "1" +bytesio = { path = "../../bytesio", default-features = false } diff --git a/video/utils/exp_golomb/src/lib.rs b/video/utils/exp_golomb/src/lib.rs new file mode 100644 index 00000000..849325ea --- /dev/null +++ b/video/utils/exp_golomb/src/lib.rs @@ -0,0 +1,58 @@ +use std::io; + +use bytesio::{bit_reader::BitReader, bit_writer::BitWriter}; + +pub fn read_exp_golomb(reader: &mut BitReader) -> io::Result { + let mut leading_zeros = 0; + while !reader.read_bit()? { + leading_zeros += 1; + } + + let mut result = 1; + for _ in 0..leading_zeros { + result <<= 1; + result |= reader.read_bit()? as u64; + } + + Ok(result - 1) +} + +pub fn read_signed_exp_golomb(reader: &mut BitReader) -> io::Result { + let exp_glob = read_exp_golomb(reader)?; + + if exp_glob % 2 == 0 { + Ok(-((exp_glob / 2) as i64)) + } else { + Ok((exp_glob / 2) as i64 + 1) + } +} + +pub fn write_exp_golomb(writer: &mut BitWriter, input: u64) -> io::Result<()> { + let mut number = input + 1; + let mut leading_zeros = 0; + while number > 1 { + number >>= 1; + leading_zeros += 1; + } + + for _ in 0..leading_zeros { + writer.write_bit(false)?; + } + + writer.write_bits(input + 1, leading_zeros + 1)?; + + Ok(()) +} + +pub fn write_signed_exp_golomb(writer: &mut BitWriter, number: i64) -> io::Result<()> { + let number = if number <= 0 { + -number as u64 * 2 + } else { + number as u64 * 2 - 1 + }; + + write_exp_golomb(writer, number) +} + +#[cfg(test)] +mod tests; diff --git a/video/utils/exp_golomb/src/tests.rs b/video/utils/exp_golomb/src/tests.rs new file mode 100644 index 00000000..c23983bb --- /dev/null +++ b/video/utils/exp_golomb/src/tests.rs @@ -0,0 +1,201 @@ +use bytesio::{bit_reader::BitReader, bit_writer::BitWriter}; + +use crate::{read_exp_golomb, read_signed_exp_golomb, write_exp_golomb, write_signed_exp_golomb}; + +#[test] +fn test_exp_glob_decode() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bits(0b1, 1).unwrap(); // 0 + bit_writer.write_bits(0b010, 3).unwrap(); // 1 + bit_writer.write_bits(0b011, 3).unwrap(); // 2 + bit_writer.write_bits(0b00100, 5).unwrap(); // 3 + bit_writer.write_bits(0b00101, 5).unwrap(); // 4 + bit_writer.write_bits(0b00110, 5).unwrap(); // 5 + bit_writer.write_bits(0b00111, 5).unwrap(); // 6 + + let data = bit_writer.into_inner(); + + let mut bit_reader = BitReader::from(data); + + let remaining_bits = bit_reader.remaining_bits(); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 0); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 1); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 4); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 7); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 12); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 4); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 17); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 5); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 22); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 6); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 27); +} + +#[test] +fn test_signed_exp_glob_decode() { + let mut bit_writer = BitWriter::default(); + + bit_writer.write_bits(0b1, 1).unwrap(); // 0 + bit_writer.write_bits(0b010, 3).unwrap(); // 1 + bit_writer.write_bits(0b011, 3).unwrap(); // -1 + bit_writer.write_bits(0b00100, 5).unwrap(); // 2 + bit_writer.write_bits(0b00101, 5).unwrap(); // -2 + bit_writer.write_bits(0b00110, 5).unwrap(); // 3 + bit_writer.write_bits(0b00111, 5).unwrap(); // -3 + + let data = bit_writer.into_inner(); + + let mut bit_reader = BitReader::from(data); + + let remaining_bits = bit_reader.remaining_bits(); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 0); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 1); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 4); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 7); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 12); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 17); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 22); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 27); +} + +#[test] +fn test_exp_glob_encode() { + let mut bit_writer = BitWriter::default(); + + write_exp_golomb(&mut bit_writer, 0).unwrap(); + write_exp_golomb(&mut bit_writer, 1).unwrap(); + write_exp_golomb(&mut bit_writer, 2).unwrap(); + write_exp_golomb(&mut bit_writer, 3).unwrap(); + write_exp_golomb(&mut bit_writer, 4).unwrap(); + write_exp_golomb(&mut bit_writer, 5).unwrap(); + write_exp_golomb(&mut bit_writer, 6).unwrap(); + write_exp_golomb(&mut bit_writer, u64::MAX - 1).unwrap(); + + let data = bit_writer.into_inner(); + + let mut bit_reader = BitReader::from(data); + + let remaining_bits = bit_reader.remaining_bits(); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 0); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 1); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 4); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 7); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 12); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 4); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 17); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 5); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 22); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 6); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 27); + + let result = read_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, u64::MAX - 1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 154); +} + +#[test] +fn test_signed_exp_glob_encode() { + let mut bit_writer = BitWriter::default(); + + write_signed_exp_golomb(&mut bit_writer, 0).unwrap(); + write_signed_exp_golomb(&mut bit_writer, 1).unwrap(); + write_signed_exp_golomb(&mut bit_writer, -1).unwrap(); + write_signed_exp_golomb(&mut bit_writer, 2).unwrap(); + write_signed_exp_golomb(&mut bit_writer, -2).unwrap(); + write_signed_exp_golomb(&mut bit_writer, 3).unwrap(); + write_signed_exp_golomb(&mut bit_writer, -3).unwrap(); + write_signed_exp_golomb(&mut bit_writer, i64::MAX).unwrap(); + + let data = bit_writer.into_inner(); + + let mut bit_reader = BitReader::from(data); + + let remaining_bits = bit_reader.remaining_bits(); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 0); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 1); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 4); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -1); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 7); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 12); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -2); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 17); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, 3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 22); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, -3); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 27); + + let result = read_signed_exp_golomb(&mut bit_reader).unwrap(); + assert_eq!(result, i64::MAX); + assert_eq!(bit_reader.remaining_bits(), remaining_bits - 154); +} diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index bbc17c16..00000000 --- a/yarn.lock +++ /dev/null @@ -1,5462 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@ampproject/remapping@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" - integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== - dependencies: - "@jridgewell/gen-mapping" "^0.1.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@ardatan/relay-compiler@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@ardatan/relay-compiler/-/relay-compiler-12.0.0.tgz#2e4cca43088e807adc63450e8cab037020e91106" - integrity sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q== - dependencies: - "@babel/core" "^7.14.0" - "@babel/generator" "^7.14.0" - "@babel/parser" "^7.14.0" - "@babel/runtime" "^7.0.0" - "@babel/traverse" "^7.14.0" - "@babel/types" "^7.0.0" - babel-preset-fbjs "^3.4.0" - chalk "^4.0.0" - fb-watchman "^2.0.0" - fbjs "^3.0.0" - glob "^7.1.1" - immutable "~3.7.6" - invariant "^2.2.4" - nullthrows "^1.1.1" - relay-runtime "12.0.0" - signedsource "^1.0.0" - yargs "^15.3.1" - -"@ardatan/sync-fetch@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@ardatan/sync-fetch/-/sync-fetch-0.0.1.tgz#3385d3feedceb60a896518a1db857ec1e945348f" - integrity sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA== - dependencies: - node-fetch "^2.6.1" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/compat-data@^7.20.5": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.0.tgz#c241dc454e5b5917e40d37e525e2f4530c399298" - integrity sha512-gMuZsmsgxk/ENC3O/fRw5QY8A9/uxQbbCEypnLIiYYc/qVJtEV7ouxC3EllIIwNzMqAQee5tanFabWsUOutS7g== - -"@babel/core@^7.14.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.0.tgz#1341aefdcc14ccc7553fcc688dd8986a2daffc13" - integrity sha512-PuxUbxcW6ZYe656yL3EAhpy7qXKq0DmYsrJLpbB8XrsCP9Nm+XCg9XFMb5vIDliPD7+U/+M+QJlH17XOcB7eXA== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.21.0" - "@babel/helper-compilation-targets" "^7.20.7" - "@babel/helper-module-transforms" "^7.21.0" - "@babel/helpers" "^7.21.0" - "@babel/parser" "^7.21.0" - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.0" - "@babel/types" "^7.21.0" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.2" - semver "^6.3.0" - -"@babel/generator@^7.14.0", "@babel/generator@^7.18.13", "@babel/generator@^7.21.0", "@babel/generator@^7.21.1": - version "7.21.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd" - integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA== - dependencies: - "@babel/types" "^7.21.0" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/helper-annotate-as-pure@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" - integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz#a6cd33e93629f5eb473b021aac05df62c4cd09bb" - integrity sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ== - dependencies: - "@babel/compat-data" "^7.20.5" - "@babel/helper-validator-option" "^7.18.6" - browserslist "^4.21.3" - lru-cache "^5.1.1" - semver "^6.3.0" - -"@babel/helper-create-class-features-plugin@^7.18.6": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.0.tgz#64f49ecb0020532f19b1d014b03bccaa1ab85fb9" - integrity sha512-Q8wNiMIdwsv5la5SPxNYzzkPnjgC0Sy0i7jLkVOCdllu/xcVNkr3TeZzbHBJrj+XXRqzX5uCyCoV9eu6xUG7KQ== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-member-expression-to-functions" "^7.21.0" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-replace-supers" "^7.20.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0" - "@babel/helper-split-export-declaration" "^7.18.6" - -"@babel/helper-environment-visitor@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" - integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== - -"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz#d552829b10ea9f120969304023cd0645fa00b1b4" - integrity sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg== - dependencies: - "@babel/template" "^7.20.7" - "@babel/types" "^7.21.0" - -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-member-expression-to-functions@^7.20.7", "@babel/helper-member-expression-to-functions@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.0.tgz#319c6a940431a133897148515877d2f3269c3ba5" - integrity sha512-Muu8cdZwNN6mRRNG6lAYErJ5X3bRevgYR2O8wN0yn7jJSnGDu6eG59RfT29JHxGUovyfrh6Pj0XzmR7drNVL3Q== - dependencies: - "@babel/types" "^7.21.0" - -"@babel/helper-module-imports@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" - integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-module-transforms@^7.21.0", "@babel/helper-module-transforms@^7.21.2": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz#160caafa4978ac8c00ac66636cb0fa37b024e2d2" - integrity sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.20.2" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/helper-validator-identifier" "^7.19.1" - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.2" - "@babel/types" "^7.21.2" - -"@babel/helper-optimise-call-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" - integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.8.0": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz#d1b9000752b18d0877cff85a5c376ce5c3121629" - integrity sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ== - -"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.20.7.tgz#243ecd2724d2071532b2c8ad2f0f9f083bcae331" - integrity sha512-vujDMtB6LVfNW13jhlCrp48QNslK6JXi7lQG736HVbHz/mbf4Dc7tIRh1Xf5C0rF7BP8iiSxGMCmY6Ci1ven3A== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.20.7" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.20.7" - "@babel/types" "^7.20.7" - -"@babel/helper-simple-access@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9" - integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA== - dependencies: - "@babel/types" "^7.20.2" - -"@babel/helper-skip-transparent-expression-wrappers@^7.20.0": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz#fbe4c52f60518cab8140d77101f0e63a8a230684" - integrity sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg== - dependencies: - "@babel/types" "^7.20.0" - -"@babel/helper-split-export-declaration@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" - integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-string-parser@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" - integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== - -"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/helper-validator-option@^7.18.6": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz#8224c7e13ace4bafdc4004da2cf064ef42673180" - integrity sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ== - -"@babel/helpers@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.21.0.tgz#9dd184fb5599862037917cdc9eecb84577dc4e7e" - integrity sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA== - dependencies: - "@babel/template" "^7.20.7" - "@babel/traverse" "^7.21.0" - "@babel/types" "^7.21.0" - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/parser@^7.14.0", "@babel/parser@^7.16.8", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.21.2": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3" - integrity sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ== - -"@babel/plugin-proposal-class-properties@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" - integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-object-rest-spread@^7.0.0": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz#aa662940ef425779c75534a5c41e9d936edc390a" - integrity sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg== - dependencies: - "@babel/compat-data" "^7.20.5" - "@babel/helper-compilation-targets" "^7.20.7" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.20.7" - -"@babel/plugin-syntax-class-properties@^7.0.0": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-flow@^7.0.0", "@babel/plugin-syntax-flow@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz#774d825256f2379d06139be0c723c4dd444f3ca1" - integrity sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-import-assertions@7.20.0": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz#bb50e0d4bea0957235390641209394e87bdb9cc4" - integrity sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ== - dependencies: - "@babel/helper-plugin-utils" "^7.19.0" - -"@babel/plugin-syntax-jsx@^7.0.0", "@babel/plugin-syntax-jsx@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" - integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-transform-arrow-functions@^7.0.0": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.20.7.tgz#bea332b0e8b2dab3dafe55a163d8227531ab0551" - integrity sha512-3poA5E7dzDomxj9WXWwuD6A5F3kc7VXwIJO+E+J8qtDtS+pXPAhrgEyh+9GBwBgPq1Z+bB+/JD60lp5jsN7JPQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-block-scoped-functions@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8" - integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-block-scoping@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.21.0.tgz#e737b91037e5186ee16b76e7ae093358a5634f02" - integrity sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-classes@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz#f469d0b07a4c5a7dbb21afad9e27e57b47031665" - integrity sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-compilation-targets" "^7.20.7" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/helper-replace-supers" "^7.20.7" - "@babel/helper-split-export-declaration" "^7.18.6" - globals "^11.1.0" - -"@babel/plugin-transform-computed-properties@^7.0.0": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.20.7.tgz#704cc2fd155d1c996551db8276d55b9d46e4d0aa" - integrity sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/template" "^7.20.7" - -"@babel/plugin-transform-destructuring@^7.0.0": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.7.tgz#8bda578f71620c7de7c93af590154ba331415454" - integrity sha512-Xwg403sRrZb81IVB79ZPqNQME23yhugYVqgTxAhT99h485F4f+GMELFhhOsscDUB7HCswepKeCKLn/GZvUKoBA== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-flow-strip-types@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.21.0.tgz#6aeca0adcb81dc627c8986e770bfaa4d9812aff5" - integrity sha512-FlFA2Mj87a6sDkW4gfGrQQqwY/dLlBAyJa2dJEZ+FHXUVHBflO2wyKvg+OOEzXfrKYIa4HWl0mgmbCzt0cMb7w== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/plugin-syntax-flow" "^7.18.6" - -"@babel/plugin-transform-for-of@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.0.tgz#964108c9988de1a60b4be2354a7d7e245f36e86e" - integrity sha512-LlUYlydgDkKpIY7mcBWvyPPmMcOphEyYA27Ef4xpbh1IiDNLr0kZsos2nf92vz3IccvJI25QUwp86Eo5s6HmBQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-function-name@^7.0.0": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0" - integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ== - dependencies: - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-literals@^7.0.0": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc" - integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-member-expression-literals@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e" - integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-modules-commonjs@^7.0.0": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.2.tgz#6ff5070e71e3192ef2b7e39820a06fb78e3058e7" - integrity sha512-Cln+Yy04Gxua7iPdj6nOV96smLGjpElir5YwzF0LBPKoPlLDNJePNlrGGaybAJkd0zKRnOVXOgizSqPYMNYkzA== - dependencies: - "@babel/helper-module-transforms" "^7.21.2" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/helper-simple-access" "^7.20.2" - -"@babel/plugin-transform-object-super@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c" - integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.6" - -"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.7.tgz#0ee349e9d1bc96e78e3b37a7af423a4078a7083f" - integrity sha512-WiWBIkeHKVOSYPO0pWkxGPfKeWrCJyD3NJ53+Lrp/QMSZbsVPovrVl2aWZ19D/LTVnaDv5Ap7GJ/B2CTOZdrfA== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-property-literals@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3" - integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-react-display-name@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz#8b1125f919ef36ebdfff061d664e266c666b9415" - integrity sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-react-jsx@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.21.0.tgz#656b42c2fdea0a6d8762075d58ef9d4e3c4ab8a2" - integrity sha512-6OAWljMvQrZjR2DaNhVfRz6dkCAVV+ymcLUmaf8bccGOHn2v5rHJK3tTpij0BuhdYWP4LLaqj5lwcdlpAAPuvg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/plugin-syntax-jsx" "^7.18.6" - "@babel/types" "^7.21.0" - -"@babel/plugin-transform-shorthand-properties@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9" - integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-spread@^7.0.0": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz#c2d83e0b99d3bf83e07b11995ee24bf7ca09401e" - integrity sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0" - -"@babel/plugin-transform-template-literals@^7.0.0": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e" - integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/runtime@^7.0.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" - integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== - dependencies: - regenerator-runtime "^0.13.11" - -"@babel/template@^7.18.10", "@babel/template@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" - integrity sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - -"@babel/traverse@^7.14.0", "@babel/traverse@^7.16.8", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.2.tgz#ac7e1f27658750892e815e60ae90f382a46d8e75" - integrity sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.21.1" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.21.0" - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.21.2" - "@babel/types" "^7.21.2" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/types@^7.0.0", "@babel/types@^7.16.8", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.2.tgz#92246f6e00f91755893c2876ad653db70c8310d1" - integrity sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw== - dependencies: - "@babel/helper-string-parser" "^7.19.4" - "@babel/helper-validator-identifier" "^7.19.1" - to-fast-properties "^2.0.0" - -"@commitlint/cli@^17.4.3", "@commitlint/cli@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/cli/-/cli-17.4.4.tgz#36df08bfa31dbb9a2b6b1d7187a31e578f001a06" - integrity sha512-HwKlD7CPVMVGTAeFZylVNy14Vm5POVY0WxPkZr7EXLC/os0LH/obs6z4HRvJtH/nHCMYBvUBQhGwnufKfTjd5g== - dependencies: - "@commitlint/format" "^17.4.4" - "@commitlint/lint" "^17.4.4" - "@commitlint/load" "^17.4.4" - "@commitlint/read" "^17.4.4" - "@commitlint/types" "^17.4.4" - execa "^5.0.0" - lodash.isfunction "^3.0.9" - resolve-from "5.0.0" - resolve-global "1.0.0" - yargs "^17.0.0" - -"@commitlint/config-conventional@^17.4.3": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/config-conventional/-/config-conventional-17.4.4.tgz#f30b1e5b2e48ce5799a483c200c52f218a98efcc" - integrity sha512-u6ztvxqzi6NuhrcEDR7a+z0yrh11elY66nRrQIpqsqW6sZmpxYkDLtpRH8jRML+mmxYQ8s4qqF06Q/IQx5aJeQ== - dependencies: - conventional-changelog-conventionalcommits "^5.0.0" - -"@commitlint/config-validator@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/config-validator/-/config-validator-17.4.4.tgz#d0742705719559a101d2ee49c0c514044af6d64d" - integrity sha512-bi0+TstqMiqoBAQDvdEP4AFh0GaKyLFlPPEObgI29utoKEYoPQTvF0EYqIwYYLEoJYhj5GfMIhPHJkTJhagfeg== - dependencies: - "@commitlint/types" "^17.4.4" - ajv "^8.11.0" - -"@commitlint/ensure@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/ensure/-/ensure-17.4.4.tgz#a36e7719bdb9c2b86c8b8c2e852b463a7bfda5fa" - integrity sha512-AHsFCNh8hbhJiuZ2qHv/m59W/GRE9UeOXbkOqxYMNNg9pJ7qELnFcwj5oYpa6vzTSHtPGKf3C2yUFNy1GGHq6g== - dependencies: - "@commitlint/types" "^17.4.4" - lodash.camelcase "^4.3.0" - lodash.kebabcase "^4.1.1" - lodash.snakecase "^4.1.1" - lodash.startcase "^4.4.0" - lodash.upperfirst "^4.3.1" - -"@commitlint/execute-rule@^17.4.0": - version "17.4.0" - resolved "https://registry.yarnpkg.com/@commitlint/execute-rule/-/execute-rule-17.4.0.tgz#4518e77958893d0a5835babe65bf87e2638f6939" - integrity sha512-LIgYXuCSO5Gvtc0t9bebAMSwd68ewzmqLypqI2Kke1rqOqqDbMpYcYfoPfFlv9eyLIh4jocHWwCK5FS7z9icUA== - -"@commitlint/format@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/format/-/format-17.4.4.tgz#0f6e1b4d7a301c7b1dfd4b6334edd97fc050b9f5" - integrity sha512-+IS7vpC4Gd/x+uyQPTAt3hXs5NxnkqAZ3aqrHd5Bx/R9skyCAWusNlNbw3InDbAK6j166D9asQM8fnmYIa+CXQ== - dependencies: - "@commitlint/types" "^17.4.4" - chalk "^4.1.0" - -"@commitlint/is-ignored@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/is-ignored/-/is-ignored-17.4.4.tgz#82e03f1abe2de2c0c8c162a250b8d466225e922b" - integrity sha512-Y3eo1SFJ2JQDik4rWkBC4tlRIxlXEFrRWxcyrzb1PUT2k3kZ/XGNuCDfk/u0bU2/yS0tOA/mTjFsV+C4qyACHw== - dependencies: - "@commitlint/types" "^17.4.4" - semver "7.3.8" - -"@commitlint/lint@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/lint/-/lint-17.4.4.tgz#0ecd70b44ec5f4823c2e00e0c4b04ebd41d42856" - integrity sha512-qgkCRRFjyhbMDWsti/5jRYVJkgYZj4r+ZmweZObnbYqPUl5UKLWMf9a/ZZisOI4JfiPmRktYRZ2JmqlSvg+ccw== - dependencies: - "@commitlint/is-ignored" "^17.4.4" - "@commitlint/parse" "^17.4.4" - "@commitlint/rules" "^17.4.4" - "@commitlint/types" "^17.4.4" - -"@commitlint/load@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-17.4.4.tgz#13fcb553572f265339801cde6dd10ee5eea07f5e" - integrity sha512-z6uFIQ7wfKX5FGBe1AkOF4l/ShOQsaa1ml/nLMkbW7R/xF8galGS7Zh0yHvzVp/srtfS0brC+0bUfQfmpMPFVQ== - dependencies: - "@commitlint/config-validator" "^17.4.4" - "@commitlint/execute-rule" "^17.4.0" - "@commitlint/resolve-extends" "^17.4.4" - "@commitlint/types" "^17.4.4" - "@types/node" "*" - chalk "^4.1.0" - cosmiconfig "^8.0.0" - cosmiconfig-typescript-loader "^4.0.0" - lodash.isplainobject "^4.0.6" - lodash.merge "^4.6.2" - lodash.uniq "^4.5.0" - resolve-from "^5.0.0" - ts-node "^10.8.1" - typescript "^4.6.4" - -"@commitlint/message@^17.4.2": - version "17.4.2" - resolved "https://registry.yarnpkg.com/@commitlint/message/-/message-17.4.2.tgz#f4753a79701ad6db6db21f69076e34de6580e22c" - integrity sha512-3XMNbzB+3bhKA1hSAWPCQA3lNxR4zaeQAQcHj0Hx5sVdO6ryXtgUBGGv+1ZCLMgAPRixuc6en+iNAzZ4NzAa8Q== - -"@commitlint/parse@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/parse/-/parse-17.4.4.tgz#8311b12f2b730de6ea0679ae2a37b386bcc5b04b" - integrity sha512-EKzz4f49d3/OU0Fplog7nwz/lAfXMaDxtriidyGF9PtR+SRbgv4FhsfF310tKxs6EPj8Y+aWWuX3beN5s+yqGg== - dependencies: - "@commitlint/types" "^17.4.4" - conventional-changelog-angular "^5.0.11" - conventional-commits-parser "^3.2.2" - -"@commitlint/read@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/read/-/read-17.4.4.tgz#de6ec00aad827764153009aa54517e3df2154555" - integrity sha512-B2TvUMJKK+Svzs6eji23WXsRJ8PAD+orI44lVuVNsm5zmI7O8RSGJMvdEZEikiA4Vohfb+HevaPoWZ7PiFZ3zA== - dependencies: - "@commitlint/top-level" "^17.4.0" - "@commitlint/types" "^17.4.4" - fs-extra "^11.0.0" - git-raw-commits "^2.0.0" - minimist "^1.2.6" - -"@commitlint/resolve-extends@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/resolve-extends/-/resolve-extends-17.4.4.tgz#8f931467dea8c43b9fe38373e303f7c220de6fdc" - integrity sha512-znXr1S0Rr8adInptHw0JeLgumS11lWbk5xAWFVno+HUFVN45875kUtqjrI6AppmD3JI+4s0uZlqqlkepjJd99A== - dependencies: - "@commitlint/config-validator" "^17.4.4" - "@commitlint/types" "^17.4.4" - import-fresh "^3.0.0" - lodash.mergewith "^4.6.2" - resolve-from "^5.0.0" - resolve-global "^1.0.0" - -"@commitlint/rules@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/rules/-/rules-17.4.4.tgz#9b33f41e5eb529f916396bac7c62e61f0edd6791" - integrity sha512-0tgvXnHi/mVcyR8Y8mjTFZIa/FEQXA4uEutXS/imH2v1UNkYDSEMsK/68wiXRpfW1euSgEdwRkvE1z23+yhNrQ== - dependencies: - "@commitlint/ensure" "^17.4.4" - "@commitlint/message" "^17.4.2" - "@commitlint/to-lines" "^17.4.0" - "@commitlint/types" "^17.4.4" - execa "^5.0.0" - -"@commitlint/to-lines@^17.4.0": - version "17.4.0" - resolved "https://registry.yarnpkg.com/@commitlint/to-lines/-/to-lines-17.4.0.tgz#9bd02e911e7d4eab3fb4a50376c4c6d331e10d8d" - integrity sha512-LcIy/6ZZolsfwDUWfN1mJ+co09soSuNASfKEU5sCmgFCvX5iHwRYLiIuoqXzOVDYOy7E7IcHilr/KS0e5T+0Hg== - -"@commitlint/top-level@^17.4.0": - version "17.4.0" - resolved "https://registry.yarnpkg.com/@commitlint/top-level/-/top-level-17.4.0.tgz#540cac8290044cf846fbdd99f5cc51e8ac5f27d6" - integrity sha512-/1loE/g+dTTQgHnjoCy0AexKAEFyHsR2zRB4NWrZ6lZSMIxAhBJnmCqwao7b4H8888PsfoTBCLBYIw8vGnej8g== - dependencies: - find-up "^5.0.0" - -"@commitlint/types@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/types/-/types-17.4.4.tgz#1416df936e9aad0d6a7bbc979ecc31e55dade662" - integrity sha512-amRN8tRLYOsxRr6mTnGGGvB5EmW/4DDjLMgiwK3CCVEmN6Sr/6xePGEpWaspKkckILuUORCwe6VfDBw6uj4axQ== - dependencies: - chalk "^4.1.0" - -"@cspotcode/source-map-support@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" - integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== - dependencies: - "@jridgewell/trace-mapping" "0.3.9" - -"@esbuild/android-arm64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz#cf91e86df127aa3d141744edafcba0abdc577d23" - integrity sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg== - -"@esbuild/android-arm@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.16.17.tgz#025b6246d3f68b7bbaa97069144fb5fb70f2fff2" - integrity sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw== - -"@esbuild/android-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.16.17.tgz#c820e0fef982f99a85c4b8bfdd582835f04cd96e" - integrity sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ== - -"@esbuild/darwin-arm64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz#edef4487af6b21afabba7be5132c26d22379b220" - integrity sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w== - -"@esbuild/darwin-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz#42829168730071c41ef0d028d8319eea0e2904b4" - integrity sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg== - -"@esbuild/freebsd-arm64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz#1f4af488bfc7e9ced04207034d398e793b570a27" - integrity sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw== - -"@esbuild/freebsd-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz#636306f19e9bc981e06aa1d777302dad8fddaf72" - integrity sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug== - -"@esbuild/linux-arm64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz#a003f7ff237c501e095d4f3a09e58fc7b25a4aca" - integrity sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g== - -"@esbuild/linux-arm@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz#b591e6a59d9c4fe0eeadd4874b157ab78cf5f196" - integrity sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ== - -"@esbuild/linux-ia32@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz#24333a11027ef46a18f57019450a5188918e2a54" - integrity sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg== - -"@esbuild/linux-loong64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz#d5ad459d41ed42bbd4d005256b31882ec52227d8" - integrity sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ== - -"@esbuild/linux-mips64el@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz#4e5967a665c38360b0a8205594377d4dcf9c3726" - integrity sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw== - -"@esbuild/linux-ppc64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz#206443a02eb568f9fdf0b438fbd47d26e735afc8" - integrity sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g== - -"@esbuild/linux-riscv64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz#c351e433d009bf256e798ad048152c8d76da2fc9" - integrity sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw== - -"@esbuild/linux-s390x@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz#661f271e5d59615b84b6801d1c2123ad13d9bd87" - integrity sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w== - -"@esbuild/linux-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz#e4ba18e8b149a89c982351443a377c723762b85f" - integrity sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw== - -"@esbuild/netbsd-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz#7d4f4041e30c5c07dd24ffa295c73f06038ec775" - integrity sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA== - -"@esbuild/openbsd-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz#970fa7f8470681f3e6b1db0cc421a4af8060ec35" - integrity sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg== - -"@esbuild/sunos-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz#abc60e7c4abf8b89fb7a4fe69a1484132238022c" - integrity sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw== - -"@esbuild/win32-arm64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz#7b0ff9e8c3265537a7a7b1fd9a24e7bd39fcd87a" - integrity sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw== - -"@esbuild/win32-ia32@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz#e90fe5267d71a7b7567afdc403dfd198c292eb09" - integrity sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig== - -"@esbuild/win32-x64@0.16.17": - version "0.16.17" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz#c5a1a4bfe1b57f0c3e61b29883525c6da3e5c091" - integrity sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q== - -"@eslint/eslintrc@^1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" - integrity sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.4.0" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@fontsource/be-vietnam-pro@^4.5.8": - version "4.5.8" - resolved "https://registry.yarnpkg.com/@fontsource/be-vietnam-pro/-/be-vietnam-pro-4.5.8.tgz#fb26070f83ab763df8952a8bb89632b7f1dff7c3" - integrity sha512-02BI3zS+7Rs4vffa2U6AznQEjG2skMn4nswjEzwQc7uzE04n1MLxh9Mhf3KT5GBuP9HuPHJYVgytcGQ1xNLJfw== - -"@fontsource/comfortaa@^4.5.11": - version "4.5.11" - resolved "https://registry.yarnpkg.com/@fontsource/comfortaa/-/comfortaa-4.5.11.tgz#f3dc5e07637fc26b87e80897c611d60229d5e24c" - integrity sha512-KKC2C6KbF9BD6m9+wMf5hK0wFjIi3p3J/6C4JZW6OF9G6K4qZJFp2dBZzsEBepKh4s9/Q5G1SWsUUZY3ZeZNDA== - -"@fortawesome/fontawesome-common-types@6.3.0": - version "6.3.0" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz#51f734e64511dbc3674cd347044d02f4dd26e86b" - integrity sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg== - -"@fortawesome/free-solid-svg-icons@^6.3.0": - version "6.3.0" - resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz#d3bd33ae18bb15fdfc3ca136e2fea05f32768a65" - integrity sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA== - dependencies: - "@fortawesome/fontawesome-common-types" "6.3.0" - -"@graphql-codegen/add@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/add/-/add-4.0.1.tgz#c187af820fdd2fc7a9c1c2453bc389cd4e16699e" - integrity sha512-A7k+9eRfrKyyNfhWEN/0eKz09R5cp4XXxUuNLQAVm/aohmVI2xdMV4lM02rTlM6Pyou3cU/v0iZnhgo6IRpqeg== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - tslib "~2.5.0" - -"@graphql-codegen/cli@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-3.2.1.tgz#7cd14ba5fdab02fc8d60bf20060bb78de57a2fdf" - integrity sha512-AeXzOvhSgAyMC0TzIoc6/HIc2Fy2rCZJcs5pt1LDypn1k4gpGRzqZ5JOjYx+XIna2hLfB9NbAkcO5dcdHwFdJA== - dependencies: - "@babel/generator" "^7.18.13" - "@babel/template" "^7.18.10" - "@babel/types" "^7.18.13" - "@graphql-codegen/core" "^3.1.0" - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-tools/apollo-engine-loader" "^7.3.6" - "@graphql-tools/code-file-loader" "^7.3.17" - "@graphql-tools/git-loader" "^7.2.13" - "@graphql-tools/github-loader" "^7.3.20" - "@graphql-tools/graphql-file-loader" "^7.5.0" - "@graphql-tools/json-file-loader" "^7.4.1" - "@graphql-tools/load" "^7.8.0" - "@graphql-tools/prisma-loader" "^7.2.49" - "@graphql-tools/url-loader" "^7.13.2" - "@graphql-tools/utils" "^9.0.0" - "@parcel/watcher" "^2.1.0" - "@whatwg-node/fetch" "^0.8.0" - chalk "^4.1.0" - cosmiconfig "^7.0.0" - cosmiconfig-typescript-loader "^4.3.0" - debounce "^1.2.0" - detect-indent "^6.0.0" - graphql-config "^4.4.0" - inquirer "^8.0.0" - is-glob "^4.0.1" - json-to-pretty-yaml "^1.2.2" - listr2 "^4.0.5" - log-symbols "^4.0.0" - micromatch "^4.0.5" - shell-quote "^1.7.3" - string-env-interpolation "^1.0.1" - ts-log "^2.2.3" - ts-node "^10.9.1" - tslib "^2.4.0" - yaml "^1.10.0" - yargs "^17.0.0" - -"@graphql-codegen/client-preset@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/client-preset/-/client-preset-2.1.0.tgz#7096d3dba7d28b24da97f6d8bbc61654a8d0f4fe" - integrity sha512-mt5CyPwZmOUP+ifC56xMjeEyfywu0P6HSWbhWPn1Jbv7n3TMILXMDfgOAufnOmrU1Ian8wu72I9A5IMRGqmW1w== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/template" "^7.20.7" - "@graphql-codegen/add" "^4.0.1" - "@graphql-codegen/gql-tag-operations" "2.0.1" - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/typed-document-node" "^3.0.1" - "@graphql-codegen/typescript" "^3.0.1" - "@graphql-codegen/typescript-operations" "^3.0.1" - "@graphql-codegen/visitor-plugin-common" "^3.0.1" - "@graphql-tools/documents" "^0.1.0" - "@graphql-tools/utils" "^9.0.0" - "@graphql-typed-document-node/core" "3.1.1" - tslib "~2.5.0" - -"@graphql-codegen/core@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/core/-/core-3.1.0.tgz#ad859d52d509a4eb2ebe5aabba6543a628fb181b" - integrity sha512-DH1/yaR7oJE6/B+c6ZF2Tbdh7LixF1K8L+8BoSubjNyQ8pNwR4a70mvc1sv6H7qgp6y1bPQ9tKE+aazRRshysw== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-tools/schema" "^9.0.0" - "@graphql-tools/utils" "^9.1.1" - tslib "~2.5.0" - -"@graphql-codegen/gql-tag-operations@2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-2.0.1.tgz#207a13ba1773e50a05d69024675af7c21f5b3d64" - integrity sha512-BGJRfRYJo566x3nPoEwiU0KkhbBAB2i4UsUg2wAlzC+z8uoL1JtCI2besa7RoWxjvEpmjrn23O5CnUzD933JLg== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/visitor-plugin-common" "3.0.1" - "@graphql-tools/utils" "^9.0.0" - auto-bind "~4.0.0" - tslib "~2.5.0" - -"@graphql-codegen/introspection@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/introspection/-/introspection-3.0.1.tgz#403c9bb12abf998a3bd37a519eb0fd01b537d64d" - integrity sha512-D6vJQTEL/np4EmeUHm5spLK59cr+AMXEoLRoTI+dagFzlHYDTfXZH6F7uhKaakxcj0SAQhIWKvGMggotUdEtyg== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/visitor-plugin-common" "^3.0.1" - tslib "~2.5.0" - -"@graphql-codegen/plugin-helpers@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/plugin-helpers/-/plugin-helpers-4.1.0.tgz#4b193c12d6bb458f1f2af48c200bc86617884f60" - integrity sha512-xvSHJb9OGb5CODIls0AI1rCenLz+FuiaNPCsfHMCNsLDjOZK2u0jAQ9zUBdc/Wb+21YXZujBCc0Vm1QX+Zz0nw== - dependencies: - "@graphql-tools/utils" "^9.0.0" - change-case-all "1.0.15" - common-tags "1.8.2" - import-from "4.0.0" - lodash "~4.17.0" - tslib "~2.5.0" - -"@graphql-codegen/schema-ast@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/schema-ast/-/schema-ast-3.0.1.tgz#37b458bb57b95715a9eb4259341c856dae2a461d" - integrity sha512-rTKTi4XiW4QFZnrEqetpiYEWVsOFNoiR/v3rY9mFSttXFbIwNXPme32EspTiGWmEEdHY8UuTDtZN3vEcs/31zw== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-tools/utils" "^9.0.0" - tslib "~2.5.0" - -"@graphql-codegen/typed-document-node@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typed-document-node/-/typed-document-node-3.0.1.tgz#68ced866ac79ce32ab50bad1b2d4b268490318d4" - integrity sha512-2plPBbAJZtR72BU6n07N3nIJYlwnCWbFNoe++MQ33S2ML4KwpCiflGEJnTpiwOEhCklQLWg1FEUdEOYS2iluqw== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/visitor-plugin-common" "3.0.1" - auto-bind "~4.0.0" - change-case-all "1.0.15" - tslib "~2.5.0" - -"@graphql-codegen/typescript-document-nodes@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-document-nodes/-/typescript-document-nodes-3.0.1.tgz#2925da112c7821d92617a0b757796294b333beb6" - integrity sha512-5bTy5BJ8Ci+Pg9k5JscsjbE3rZalClLDntkL8sxfYSbW6Qth4ySG5D7wx5eBfKAVgqEkhKHNtwz6SsQ5CSys8A== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/visitor-plugin-common" "3.0.1" - auto-bind "~4.0.0" - tslib "~2.5.0" - -"@graphql-codegen/typescript-operations@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-operations/-/typescript-operations-3.0.1.tgz#a190622f15b13bdbf274d8619850e1b8eea5aba1" - integrity sha512-Td1d483cQr7XJj/zXrbqVUEi2QK56DT7EToFheZrBFArIQCUEGK+Xgw6GhEmZaTwWYODxavzy1jmTTJC2fEuTw== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/typescript" "^3.0.1" - "@graphql-codegen/visitor-plugin-common" "3.0.1" - auto-bind "~4.0.0" - tslib "~2.5.0" - -"@graphql-codegen/typescript@3.0.1", "@graphql-codegen/typescript@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript/-/typescript-3.0.1.tgz#e949a7d7e8325bea9cf938c30af2313043e91a09" - integrity sha512-HvozJg7eHqywmYvXa7+nmjw+v3+f8ilFv9VbRvmjhj/zBw3VKGT2n/85ZhVyuWjY2KrDLzl6BqeXttWsW5Wo4w== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-codegen/schema-ast" "^3.0.1" - "@graphql-codegen/visitor-plugin-common" "3.0.1" - auto-bind "~4.0.0" - tslib "~2.5.0" - -"@graphql-codegen/visitor-plugin-common@3.0.1", "@graphql-codegen/visitor-plugin-common@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-3.0.1.tgz#dbfc2ba55d8a1908e4bd7f751dbfe4241961f948" - integrity sha512-Qek+Ywy094Km7Vc1TzKBN9ICvtYwPdqZUliPO77urMSveP+2+G2O9Tjx546dW4A1O6rhEfexbenc2DqTAe7iLQ== - dependencies: - "@graphql-codegen/plugin-helpers" "^4.1.0" - "@graphql-tools/optimize" "^1.3.0" - "@graphql-tools/relay-operation-optimizer" "^6.5.0" - "@graphql-tools/utils" "^9.0.0" - auto-bind "~4.0.0" - change-case-all "1.0.15" - dependency-graph "^0.11.0" - graphql-tag "^2.11.0" - parse-filepath "^1.0.2" - tslib "~2.5.0" - -"@graphql-tools/apollo-engine-loader@^7.3.6": - version "7.3.26" - resolved "https://registry.yarnpkg.com/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-7.3.26.tgz#91e54460d5579933e42a2010b8688c3459c245d8" - integrity sha512-h1vfhdJFjnCYn9b5EY1Z91JTF0KB3hHVJNQIsiUV2mpQXZdeOXQoaWeYEKaiI5R6kwBw5PP9B0fv3jfUIG8LyQ== - dependencies: - "@ardatan/sync-fetch" "^0.0.1" - "@graphql-tools/utils" "^9.2.1" - "@whatwg-node/fetch" "^0.8.0" - tslib "^2.4.0" - -"@graphql-tools/batch-execute@^8.5.18": - version "8.5.18" - resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-8.5.18.tgz#2f0e91cc12e8eed32f14bc814f27c6a498b75e17" - integrity sha512-mNv5bpZMLLwhkmPA6+RP81A6u3KF4CSKLf3VX9hbomOkQR4db8pNs8BOvpZU54wKsUzMzdlws/2g/Dabyb2Vsg== - dependencies: - "@graphql-tools/utils" "9.2.1" - dataloader "2.2.2" - tslib "^2.4.0" - value-or-promise "1.0.12" - -"@graphql-tools/code-file-loader@^7.3.17": - version "7.3.21" - resolved "https://registry.yarnpkg.com/@graphql-tools/code-file-loader/-/code-file-loader-7.3.21.tgz#3eed4ff4610cf0a6f4b1be17d0bce1eec9359479" - integrity sha512-dj+OLnz1b8SYkXcuiy0CUQ25DWnOEyandDlOcdBqU3WVwh5EEVbn0oXUYm90fDlq2/uut00OrtC5Wpyhi3tAvA== - dependencies: - "@graphql-tools/graphql-tag-pluck" "7.5.0" - "@graphql-tools/utils" "9.2.1" - globby "^11.0.3" - tslib "^2.4.0" - unixify "^1.0.0" - -"@graphql-tools/delegate@9.0.28", "@graphql-tools/delegate@^9.0.27": - version "9.0.28" - resolved "https://registry.yarnpkg.com/@graphql-tools/delegate/-/delegate-9.0.28.tgz#026275094b2ff3f4cbbe99caff2d48775aeb67d6" - integrity sha512-8j23JCs2mgXqnp+5K0v4J3QBQU/5sXd9miaLvMfRf/6963DznOXTECyS9Gcvj1VEeR5CXIw6+aX/BvRDKDdN1g== - dependencies: - "@graphql-tools/batch-execute" "^8.5.18" - "@graphql-tools/executor" "^0.0.15" - "@graphql-tools/schema" "^9.0.16" - "@graphql-tools/utils" "^9.2.1" - dataloader "^2.2.2" - tslib "^2.5.0" - value-or-promise "^1.0.12" - -"@graphql-tools/documents@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@graphql-tools/documents/-/documents-0.1.0.tgz#9c27faea5a17ab271dbd99edd8d52eee0e43573e" - integrity sha512-1WQeovHv5S1M3xMzQxbSrG3yl6QOnsq2JUBnlg5/0aMM5R4GNMx6Ms+ROByez/dnuA81kstRuSK+2qpe+GaRIw== - dependencies: - lodash.sortby "^4.7.0" - tslib "^2.4.0" - -"@graphql-tools/executor-graphql-ws@^0.0.11": - version "0.0.11" - resolved "https://registry.yarnpkg.com/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-0.0.11.tgz#c6536aa862f76a9c7ac83e7e07fe8d5119e6de38" - integrity sha512-muRj6j897ks2iKqe3HchWFFzd+jFInSRuLPvHJ7e4WPrejFvaZx3BQ9gndfJvVkfYUZIFm13stCGXaJJTbVM0Q== - dependencies: - "@graphql-tools/utils" "9.2.1" - "@repeaterjs/repeater" "3.0.4" - "@types/ws" "^8.0.0" - graphql-ws "5.11.3" - isomorphic-ws "5.0.0" - tslib "^2.4.0" - ws "8.12.1" - -"@graphql-tools/executor-http@^0.1.7": - version "0.1.9" - resolved "https://registry.yarnpkg.com/@graphql-tools/executor-http/-/executor-http-0.1.9.tgz#ddd74ef376b4a2ed59c622acbcca068890854a30" - integrity sha512-tNzMt5qc1ptlHKfpSv9wVBVKCZ7gks6Yb/JcYJluxZIT4qRV+TtOFjpptfBU63usgrGVOVcGjzWc/mt7KhmmpQ== - dependencies: - "@graphql-tools/utils" "^9.2.1" - "@repeaterjs/repeater" "^3.0.4" - "@whatwg-node/fetch" "^0.8.1" - dset "^3.1.2" - extract-files "^11.0.0" - meros "^1.2.1" - tslib "^2.4.0" - value-or-promise "^1.0.12" - -"@graphql-tools/executor-legacy-ws@^0.0.9": - version "0.0.9" - resolved "https://registry.yarnpkg.com/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-0.0.9.tgz#1ff517998f750af2be9c1dae8924665a136e4986" - integrity sha512-L7oDv7R5yoXzMH+KLKDB2WHVijfVW4dB2H+Ae1RdW3MFvwbYjhnIB6QzHqKEqksjp/FndtxZkbuTIuAOsYGTYw== - dependencies: - "@graphql-tools/utils" "9.2.1" - "@types/ws" "^8.0.0" - isomorphic-ws "5.0.0" - tslib "^2.4.0" - ws "8.12.1" - -"@graphql-tools/executor@^0.0.15": - version "0.0.15" - resolved "https://registry.yarnpkg.com/@graphql-tools/executor/-/executor-0.0.15.tgz#cbd29af2ec54213a52f6c516a7792b3e626a4c49" - integrity sha512-6U7QLZT8cEUxAMXDP4xXVplLi6RBwx7ih7TevlBto66A/qFp3PDb6o/VFo07yBKozr8PGMZ4jMfEWBGxmbGdxA== - dependencies: - "@graphql-tools/utils" "9.2.1" - "@graphql-typed-document-node/core" "3.1.2" - "@repeaterjs/repeater" "3.0.4" - tslib "^2.4.0" - value-or-promise "1.0.12" - -"@graphql-tools/git-loader@^7.2.13": - version "7.2.20" - resolved "https://registry.yarnpkg.com/@graphql-tools/git-loader/-/git-loader-7.2.20.tgz#b17917c89be961c272bfbf205dcf32287247494b" - integrity sha512-D/3uwTzlXxG50HI8BEixqirT4xiUp6AesTdfotRXAs2d4CT9wC6yuIWOHkSBqgI1cwKWZb6KXZr467YPS5ob1w== - dependencies: - "@graphql-tools/graphql-tag-pluck" "7.5.0" - "@graphql-tools/utils" "9.2.1" - is-glob "4.0.3" - micromatch "^4.0.4" - tslib "^2.4.0" - unixify "^1.0.0" - -"@graphql-tools/github-loader@^7.3.20": - version "7.3.27" - resolved "https://registry.yarnpkg.com/@graphql-tools/github-loader/-/github-loader-7.3.27.tgz#77a2fbaeb7bf5f8edc4a865252ecb527a5399e01" - integrity sha512-fFFC35qenyhjb8pfcYXKknAt0CXP5CkQYtLfJXgTXSgBjIsfAVMrqxQ/Y0ejeM19XNF/C3VWJ7rE308yOX6ywA== - dependencies: - "@ardatan/sync-fetch" "^0.0.1" - "@graphql-tools/graphql-tag-pluck" "^7.4.6" - "@graphql-tools/utils" "^9.2.1" - "@whatwg-node/fetch" "^0.8.0" - tslib "^2.4.0" - -"@graphql-tools/graphql-file-loader@^7.3.7", "@graphql-tools/graphql-file-loader@^7.5.0": - version "7.5.16" - resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-7.5.16.tgz#d954b25ee14c6421ddcef43f4320a82e9800cb23" - integrity sha512-lK1N3Y2I634FS12nd4bu7oAJbai3bUc28yeX+boT+C83KTO4ujGHm+6hPC8X/FRGwhKOnZBxUM7I5nvb3HiUxw== - dependencies: - "@graphql-tools/import" "6.7.17" - "@graphql-tools/utils" "9.2.1" - globby "^11.0.3" - tslib "^2.4.0" - unixify "^1.0.0" - -"@graphql-tools/graphql-tag-pluck@7.5.0", "@graphql-tools/graphql-tag-pluck@^7.4.6": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-7.5.0.tgz#be99bc6b5e8331a2379ab4585d71b057eb981497" - integrity sha512-76SYzhSlH50ZWkhWH6OI94qrxa8Ww1ZeOU04MdtpSeQZVT2rjGWeTb3xM3kjTVWQJsr/YJBhDeNPGlwNUWfX4Q== - dependencies: - "@babel/parser" "^7.16.8" - "@babel/plugin-syntax-import-assertions" "7.20.0" - "@babel/traverse" "^7.16.8" - "@babel/types" "^7.16.8" - "@graphql-tools/utils" "9.2.1" - tslib "^2.4.0" - -"@graphql-tools/import@6.7.17": - version "6.7.17" - resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-6.7.17.tgz#ab51ed08bcbf757f952abf3f40793ce3db42d4a3" - integrity sha512-bn9SgrECXq3WIasgNP7ful/uON51wBajPXtxdY+z/ce7jLWaFE6lzwTDB/GAgiZ+jo7nb0ravlxteSAz2qZmuA== - dependencies: - "@graphql-tools/utils" "9.2.1" - resolve-from "5.0.0" - tslib "^2.4.0" - -"@graphql-tools/json-file-loader@^7.3.7", "@graphql-tools/json-file-loader@^7.4.1": - version "7.4.17" - resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-7.4.17.tgz#3f08e74ab1a3534c02dc97875acc7f15aa460011" - integrity sha512-KOSTP43nwjPfXgas90rLHAFgbcSep4nmiYyR9xRVz4ZAmw8VYHcKhOLTSGylCAzi7KUfyBXajoW+6Z7dQwdn3g== - dependencies: - "@graphql-tools/utils" "9.2.1" - globby "^11.0.3" - tslib "^2.4.0" - unixify "^1.0.0" - -"@graphql-tools/load@^7.5.5", "@graphql-tools/load@^7.8.0": - version "7.8.12" - resolved "https://registry.yarnpkg.com/@graphql-tools/load/-/load-7.8.12.tgz#6457fe6ec8cd2e2b5ca0d2752464bc937d186cca" - integrity sha512-JwxgNS2c6i6oIdKttcbXns/lpKiyN7c6/MkkrJ9x2QE9rXk5HOhSJxRvPmOueCuAin1542xUrcDRGBXJ7thSig== - dependencies: - "@graphql-tools/schema" "9.0.16" - "@graphql-tools/utils" "9.2.1" - p-limit "3.1.0" - tslib "^2.4.0" - -"@graphql-tools/merge@8.3.18", "@graphql-tools/merge@^8.2.6": - version "8.3.18" - resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.18.tgz#bfbb517c68598a885809f16ce5c3bb1ebb8f04a2" - integrity sha512-R8nBglvRWPAyLpZL/f3lxsY7wjnAeE0l056zHhcO/CgpvK76KYUt9oEkR05i8Hmt8DLRycBN0FiotJ0yDQWTVA== - dependencies: - "@graphql-tools/utils" "9.2.1" - tslib "^2.4.0" - -"@graphql-tools/optimize@^1.3.0": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@graphql-tools/optimize/-/optimize-1.3.1.tgz#29407991478dbbedc3e7deb8c44f46acb4e9278b" - integrity sha512-5j5CZSRGWVobt4bgRRg7zhjPiSimk+/zIuColih8E8DxuFOaJ+t0qu7eZS5KXWBkjcd4BPNuhUPpNlEmHPqVRQ== - dependencies: - tslib "^2.4.0" - -"@graphql-tools/prisma-loader@^7.2.49": - version "7.2.64" - resolved "https://registry.yarnpkg.com/@graphql-tools/prisma-loader/-/prisma-loader-7.2.64.tgz#e9fc85054b15a22a16c8e69ad4f9543da30c0164" - integrity sha512-W8GfzfBKiBSIEgw+/nJk6zUlF6k/jterlNoFhM27mBsbeMtWxKnm1+gEU6KA0N1PNEdq2RIa2W4AfVfVBl2GgQ== - dependencies: - "@graphql-tools/url-loader" "7.17.13" - "@graphql-tools/utils" "9.2.1" - "@types/js-yaml" "^4.0.0" - "@types/json-stable-stringify" "^1.0.32" - "@types/jsonwebtoken" "^9.0.0" - chalk "^4.1.0" - debug "^4.3.1" - dotenv "^16.0.0" - graphql-request "^5.0.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.0" - isomorphic-fetch "^3.0.0" - js-yaml "^4.0.0" - json-stable-stringify "^1.0.1" - jsonwebtoken "^9.0.0" - lodash "^4.17.20" - scuid "^1.1.0" - tslib "^2.4.0" - yaml-ast-parser "^0.0.43" - -"@graphql-tools/relay-operation-optimizer@^6.5.0": - version "6.5.17" - resolved "https://registry.yarnpkg.com/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.5.17.tgz#4e4e2675d696a2a31f106b09ed436c43f7976f37" - integrity sha512-hHPEX6ccRF3+9kfVz0A3In//Dej7QrHOLGZEokBmPDMDqn9CS7qUjpjyGzclbOX0tRBtLfuFUZ68ABSac3P1nA== - dependencies: - "@ardatan/relay-compiler" "12.0.0" - "@graphql-tools/utils" "9.2.1" - tslib "^2.4.0" - -"@graphql-tools/schema@9.0.16", "@graphql-tools/schema@^9.0.0", "@graphql-tools/schema@^9.0.16": - version "9.0.16" - resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.16.tgz#7d340d69e6094dc01a2b9e625c7bb4fff89ea521" - integrity sha512-kF+tbYPPf/6K2aHG3e1SWIbapDLQaqnIHVRG6ow3onkFoowwtKszvUyOASL6Krcv2x9bIMvd1UkvRf9OaoROQQ== - dependencies: - "@graphql-tools/merge" "8.3.18" - "@graphql-tools/utils" "9.2.1" - tslib "^2.4.0" - value-or-promise "1.0.12" - -"@graphql-tools/url-loader@7.17.13", "@graphql-tools/url-loader@^7.13.2", "@graphql-tools/url-loader@^7.9.7": - version "7.17.13" - resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-7.17.13.tgz#d4ee8193792ab1c42db2fbdf5f6ca75fa819ac40" - integrity sha512-FEmbvw68kxeZLn4VYGAl+NuBPk09ZnxymjW07A6mCtiDayFgYfHdWeRzXn/iM5PzsEuCD73R1sExtNQ/ISiajg== - dependencies: - "@ardatan/sync-fetch" "^0.0.1" - "@graphql-tools/delegate" "^9.0.27" - "@graphql-tools/executor-graphql-ws" "^0.0.11" - "@graphql-tools/executor-http" "^0.1.7" - "@graphql-tools/executor-legacy-ws" "^0.0.9" - "@graphql-tools/utils" "^9.2.1" - "@graphql-tools/wrap" "^9.3.6" - "@types/ws" "^8.0.0" - "@whatwg-node/fetch" "^0.8.0" - isomorphic-ws "^5.0.0" - tslib "^2.4.0" - value-or-promise "^1.0.11" - ws "^8.12.0" - -"@graphql-tools/utils@9.2.1", "@graphql-tools/utils@^9.0.0", "@graphql-tools/utils@^9.1.1", "@graphql-tools/utils@^9.2.1": - version "9.2.1" - resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57" - integrity sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A== - dependencies: - "@graphql-typed-document-node/core" "^3.1.1" - tslib "^2.4.0" - -"@graphql-tools/wrap@^9.3.6": - version "9.3.7" - resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-9.3.7.tgz#97d7efdb8dfee41624e154b2de4499397634422e" - integrity sha512-gavfiWLKgvmC2VPamnMzml3zmkBoo0yt+EmOLIHY6O92o4uMTR281WGM77tZIfq+jzLtjoIOThUSjC/cN/6XKg== - dependencies: - "@graphql-tools/delegate" "9.0.28" - "@graphql-tools/schema" "9.0.16" - "@graphql-tools/utils" "9.2.1" - tslib "^2.4.0" - value-or-promise "1.0.12" - -"@graphql-typed-document-node/core@3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" - integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== - -"@graphql-typed-document-node/core@3.1.2", "@graphql-typed-document-node/core@^3.1.1", "@graphql-typed-document-node/core@^3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.2.tgz#6fc464307cbe3c8ca5064549b806360d84457b04" - integrity sha512-9anpBMM9mEgZN4wr2v8wHJI2/u5TnnggewRN6OlvXTTnuVyoY19X6rOv9XTqKRw6dcGKwZsBi8n0kDE2I5i4VA== - -"@humanwhocodes/config-array@^0.11.8": - version "0.11.8" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" - integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g== - dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" - minimatch "^3.0.5" - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - -"@humanwhocodes/object-schema@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== - -"@jridgewell/gen-mapping@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" - integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== - dependencies: - "@jridgewell/set-array" "^1.0.0" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@jridgewell/gen-mapping@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" - integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== - dependencies: - "@jridgewell/set-array" "^1.0.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - -"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" - integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== - -"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.4.14" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.17" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" - integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g== - dependencies: - "@jridgewell/resolve-uri" "3.1.0" - "@jridgewell/sourcemap-codec" "1.4.14" - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@parcel/watcher@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.1.0.tgz#5f32969362db4893922c526a842d8af7a8538545" - integrity sha512-8s8yYjd19pDSsBpbkOHnT6Z2+UJSuLQx61pCFM0s5wSRvKCEMDjd/cHY3/GI1szHIWbpXpsJdg3V6ISGGx9xDw== - dependencies: - is-glob "^4.0.3" - micromatch "^4.0.5" - node-addon-api "^3.2.1" - node-gyp-build "^4.3.0" - -"@peculiar/asn1-schema@^2.1.6", "@peculiar/asn1-schema@^2.3.0": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz#21418e1f3819e0b353ceff0c2dad8ccb61acd777" - integrity sha512-6GptMYDMyWBHTUKndHaDsRZUO/XMSgIns2krxcm2L7SEExRHwawFvSwNBhqNPR9HJwv3MruAiF1bhN0we6j6GQ== - dependencies: - asn1js "^3.0.5" - pvtsutils "^1.3.2" - tslib "^2.4.0" - -"@peculiar/json-schema@^1.1.12": - version "1.1.12" - resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" - integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== - dependencies: - tslib "^2.0.0" - -"@peculiar/webcrypto@^1.4.0": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz#821493bd5ad0f05939bd5f53b28536f68158360a" - integrity sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw== - dependencies: - "@peculiar/asn1-schema" "^2.3.0" - "@peculiar/json-schema" "^1.1.12" - pvtsutils "^1.3.2" - tslib "^2.4.1" - webcrypto-core "^1.7.4" - -"@playwright/test@^1.28.1": - version "1.30.0" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.30.0.tgz#8c0c4930ff2c7be7b3ec3fd434b2a3b4465ed7cb" - integrity sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw== - dependencies: - "@types/node" "*" - playwright-core "1.30.0" - -"@polka/url@^1.0.0-next.20": - version "1.0.0-next.21" - resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" - integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== - -"@repeaterjs/repeater@3.0.4", "@repeaterjs/repeater@^3.0.4": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca" - integrity sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA== - -"@rollup/plugin-commonjs@^24.0.1": - version "24.0.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz#d54ba26a3e3c495dc332bd27a81f7e9e2df46f90" - integrity sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow== - dependencies: - "@rollup/pluginutils" "^5.0.1" - commondir "^1.0.1" - estree-walker "^2.0.2" - glob "^8.0.3" - is-reference "1.2.1" - magic-string "^0.27.0" - -"@rollup/plugin-json@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-6.0.0.tgz#199fea6670fd4dfb1f4932250569b14719db234a" - integrity sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w== - dependencies: - "@rollup/pluginutils" "^5.0.1" - -"@rollup/plugin-node-resolve@^15.0.1": - version "15.0.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.0.1.tgz#72be449b8e06f6367168d5b3cd5e2802e0248971" - integrity sha512-ReY88T7JhJjeRVbfCyNj+NXAG3IIsVMsX9b5/9jC98dRP8/yxlZdz7mHZbHk5zHr24wZZICS5AcXsFZAXYUQEg== - dependencies: - "@rollup/pluginutils" "^5.0.1" - "@types/resolve" "1.20.2" - deepmerge "^4.2.2" - is-builtin-module "^3.2.0" - is-module "^1.0.0" - resolve "^1.22.1" - -"@rollup/plugin-replace@^5.0.2": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz#45f53501b16311feded2485e98419acb8448c61d" - integrity sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA== - dependencies: - "@rollup/pluginutils" "^5.0.1" - magic-string "^0.27.0" - -"@rollup/pluginutils@^5.0.1": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.2.tgz#012b8f53c71e4f6f9cb317e311df1404f56e7a33" - integrity sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA== - dependencies: - "@types/estree" "^1.0.0" - estree-walker "^2.0.2" - picomatch "^2.3.1" - -"@sveltejs/kit@^1.5.0": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-1.7.2.tgz#ec90d5f7d18a8c40e5a6d8dfae43acaa00f6b199" - integrity sha512-qU/kbupIhsA1JA0GIN4cGa6XrhzPc99Z4agsEDeGPMy7qQqYCuFcIL2MLEH+tfqPUCu4m3FQ6ULVSUIVCnHj+A== - dependencies: - "@sveltejs/vite-plugin-svelte" "^2.0.0" - "@types/cookie" "^0.5.1" - cookie "^0.5.0" - devalue "^4.2.3" - esm-env "^1.0.0" - kleur "^4.1.5" - magic-string "^0.29.0" - mime "^3.0.0" - sade "^1.8.1" - set-cookie-parser "^2.5.1" - sirv "^2.0.2" - tiny-glob "^0.2.9" - undici "5.19.1" - -"@sveltejs/vite-plugin-svelte@^2.0.0": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.0.2.tgz#943090239a31b2e0546837ff7649b73aeb46614c" - integrity sha512-xCEan0/NNpQuL0l5aS42FjwQ6wwskdxC3pW1OeFtEKNZwRg7Evro9lac9HesGP6TdFsTv2xMes5ASQVKbCacxg== - dependencies: - debug "^4.3.4" - deepmerge "^4.2.2" - kleur "^4.1.5" - magic-string "^0.27.0" - svelte-hmr "^0.15.1" - vitefu "^0.2.3" - -"@tootallnate/once@2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" - integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== - -"@tsconfig/node10@^1.0.7": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" - integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== - -"@tsconfig/node12@^1.0.7": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" - integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== - -"@tsconfig/node14@^1.0.0": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" - integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== - -"@tsconfig/node16@^1.0.2": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" - integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== - -"@types/chai-subset@^1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" - integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw== - dependencies: - "@types/chai" "*" - -"@types/chai@*", "@types/chai@^4.3.4": - version "4.3.4" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4" - integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw== - -"@types/cookie@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554" - integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g== - -"@types/estree@*", "@types/estree@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" - integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== - -"@types/fs-extra@^11.0.1": - version "11.0.1" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.1.tgz#f542ec47810532a8a252127e6e105f487e0a6ea5" - integrity sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA== - dependencies: - "@types/jsonfile" "*" - "@types/node" "*" - -"@types/js-yaml@^4.0.0": - version "4.0.5" - resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" - integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== - -"@types/json-schema@^7.0.9": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" - integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== - -"@types/json-stable-stringify@^1.0.32": - version "1.0.34" - resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.34.tgz#c0fb25e4d957e0ee2e497c1f553d7f8bb668fd75" - integrity sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw== - -"@types/jsonfile@*": - version "6.1.1" - resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.1.tgz#ac84e9aefa74a2425a0fb3012bdea44f58970f1b" - integrity sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png== - dependencies: - "@types/node" "*" - -"@types/jsonwebtoken@^9.0.0": - version "9.0.1" - resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz#29b1369c4774200d6d6f63135bf3d1ba3ef997a4" - integrity sha512-c5ltxazpWabia/4UzhIoaDcIza4KViOQhdbjRlfcIGVnsE3c3brkz9Z+F/EeJIECOQP7W7US2hNE930cWWkPiw== - dependencies: - "@types/node" "*" - -"@types/minimist@^1.2.0": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" - integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== - -"@types/node@*": - version "18.14.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0" - integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A== - -"@types/node@^18.14.2": - version "18.14.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1" - integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA== - -"@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== - -"@types/pug@^2.0.6": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.6.tgz#f830323c88172e66826d0bde413498b61054b5a6" - integrity sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg== - -"@types/resolve@1.20.2": - version "1.20.2" - resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" - integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== - -"@types/sass@^1.43.1": - version "1.43.1" - resolved "https://registry.yarnpkg.com/@types/sass/-/sass-1.43.1.tgz#86bb0168e9e881d7dade6eba16c9ed6d25dc2f68" - integrity sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g== - dependencies: - "@types/node" "*" - -"@types/semver@^7.3.12": - version "7.3.13" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" - integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== - -"@types/ws@^8.0.0": - version "8.5.4" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5" - integrity sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg== - dependencies: - "@types/node" "*" - -"@typescript-eslint/eslint-plugin@^5.45.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz#5fb0d43574c2411f16ea80f5fc335b8eaa7b28a8" - integrity sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg== - dependencies: - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/type-utils" "5.52.0" - "@typescript-eslint/utils" "5.52.0" - debug "^4.3.4" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - natural-compare-lite "^1.4.0" - regexpp "^3.2.0" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/parser@^5.45.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.52.0.tgz#73c136df6c0133f1d7870de7131ccf356f5be5a4" - integrity sha512-e2KiLQOZRo4Y0D/b+3y08i3jsekoSkOYStROYmPUnGMEoA0h+k2qOH5H6tcjIc68WDvGwH+PaOrP1XRzLJ6QlA== - dependencies: - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/typescript-estree" "5.52.0" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.52.0.tgz#a993d89a0556ea16811db48eabd7c5b72dcb83d1" - integrity sha512-AR7sxxfBKiNV0FWBSARxM8DmNxrwgnYMPwmpkC1Pl1n+eT8/I2NAUPuwDy/FmDcC6F8pBfmOcaxcxRHspgOBMw== - dependencies: - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/visitor-keys" "5.52.0" - -"@typescript-eslint/type-utils@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.52.0.tgz#9fd28cd02e6f21f5109e35496df41893f33167aa" - integrity sha512-tEKuUHfDOv852QGlpPtB3lHOoig5pyFQN/cUiZtpw99D93nEBjexRLre5sQZlkMoHry/lZr8qDAt2oAHLKA6Jw== - dependencies: - "@typescript-eslint/typescript-estree" "5.52.0" - "@typescript-eslint/utils" "5.52.0" - debug "^4.3.4" - tsutils "^3.21.0" - -"@typescript-eslint/types@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.52.0.tgz#19e9abc6afb5bd37a1a9bea877a1a836c0b3241b" - integrity sha512-oV7XU4CHYfBhk78fS7tkum+/Dpgsfi91IIDy7fjCyq2k6KB63M6gMC0YIvy+iABzmXThCRI6xpCEyVObBdWSDQ== - -"@typescript-eslint/typescript-estree@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.52.0.tgz#6408cb3c2ccc01c03c278cb201cf07e73347dfca" - integrity sha512-WeWnjanyEwt6+fVrSR0MYgEpUAuROxuAH516WPjUblIrClzYJj0kBbjdnbQXLpgAN8qbEuGywiQsXUVDiAoEuQ== - dependencies: - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/visitor-keys" "5.52.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.52.0.tgz#b260bb5a8f6b00a0ed51db66bdba4ed5e4845a72" - integrity sha512-As3lChhrbwWQLNk2HC8Ree96hldKIqk98EYvypd3It8Q1f8d5zWyIoaZEp2va5667M4ZyE7X8UUR+azXrFl+NA== - dependencies: - "@types/json-schema" "^7.0.9" - "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/typescript-estree" "5.52.0" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - semver "^7.3.7" - -"@typescript-eslint/visitor-keys@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.52.0.tgz#e38c971259f44f80cfe49d97dbffa38e3e75030f" - integrity sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA== - dependencies: - "@typescript-eslint/types" "5.52.0" - eslint-visitor-keys "^3.3.0" - -"@urql/core@^3.0.3", "@urql/core@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@urql/core/-/core-3.1.1.tgz#a49cd572360d01f2469a786b294fba2269a65e53" - integrity sha512-Mnxtq4I4QeFJsgs7Iytw+HyhiGxISR6qtyk66c9tipozLZ6QVxrCiUPF2HY4BxNIabaxcp+rivadvm8NAnXj4Q== - dependencies: - wonka "^6.1.2" - -"@urql/svelte@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@urql/svelte/-/svelte-3.0.3.tgz#4dc70862ac2f8254360adadc994f0c522f8e3e66" - integrity sha512-/vdiEdCik/7PI0HXtE/e8SaCI/2LiotYMLuVw7k4APmwpBgfeD1gMfTwm5W2EqtH7M4SOjoSERZD8kf2JsP90A== - dependencies: - "@urql/core" "^3.1.1" - wonka "^6.0.0" - -"@whatwg-node/events@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@whatwg-node/events/-/events-0.0.2.tgz#7b7107268d2982fc7b7aff5ee6803c64018f84dd" - integrity sha512-WKj/lI4QjnLuPrim0cfO7i+HsDSXHxNv1y0CrJhdntuO3hxWZmnXCwNDnwOvry11OjRin6cgWNF+j/9Pn8TN4w== - -"@whatwg-node/fetch@^0.8.0", "@whatwg-node/fetch@^0.8.1": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.8.1.tgz#ee3c94746132f217e17f78f9e073bb342043d630" - integrity sha512-Fkd1qQHK2tAWxKlC85h9L86Lgbq3BzxMnHSnTsnzNZMMzn6Xi+HlN8/LJ90LxorhSqD54td+Q864LgwUaYDj1Q== - dependencies: - "@peculiar/webcrypto" "^1.4.0" - "@whatwg-node/node-fetch" "^0.3.0" - busboy "^1.6.0" - urlpattern-polyfill "^6.0.2" - web-streams-polyfill "^3.2.1" - -"@whatwg-node/node-fetch@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@whatwg-node/node-fetch/-/node-fetch-0.3.0.tgz#7c7e90d03fa09d0ddebff29add6f16d923327d58" - integrity sha512-mPM8WnuHiI/3kFxDeE0SQQXAElbz4onqmm64fEGCwYEcBes2UsvIDI8HwQIqaXCH42A9ajJUPv4WsYoN/9oG6w== - dependencies: - "@whatwg-node/events" "^0.0.2" - busboy "^1.6.0" - fast-querystring "^1.1.1" - fast-url-parser "^1.1.3" - tslib "^2.3.1" - -JSONStream@^1.0.4: - version "1.3.5" - resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" - integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== - dependencies: - jsonparse "^1.2.0" - through ">=2.2.7 <3" - -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn-walk@^8.1.1, acorn-walk@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - -acorn@^8.4.1, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2: - version "8.8.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" - integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== - -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - -ajv@^6.10.0, ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^8.11.0: - version "8.12.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" - integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^6.0.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" - integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== - -anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-ify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" - integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -arrify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== - -asap@~2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - -asn1js@^3.0.1, asn1js@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" - integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== - dependencies: - pvtsutils "^1.3.2" - pvutils "^1.1.3" - tslib "^2.4.0" - -assertion-error@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" - integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== - -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -auto-bind@~4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb" - integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== - -babel-plugin-syntax-trailing-function-commas@^7.0.0-beta.0: - version "7.0.0-beta.0" - resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz#aa213c1435e2bffeb6fca842287ef534ad05d5cf" - integrity sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ== - -babel-preset-fbjs@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/babel-preset-fbjs/-/babel-preset-fbjs-3.4.0.tgz#38a14e5a7a3b285a3f3a86552d650dca5cf6111c" - integrity sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow== - dependencies: - "@babel/plugin-proposal-class-properties" "^7.0.0" - "@babel/plugin-proposal-object-rest-spread" "^7.0.0" - "@babel/plugin-syntax-class-properties" "^7.0.0" - "@babel/plugin-syntax-flow" "^7.0.0" - "@babel/plugin-syntax-jsx" "^7.0.0" - "@babel/plugin-syntax-object-rest-spread" "^7.0.0" - "@babel/plugin-transform-arrow-functions" "^7.0.0" - "@babel/plugin-transform-block-scoped-functions" "^7.0.0" - "@babel/plugin-transform-block-scoping" "^7.0.0" - "@babel/plugin-transform-classes" "^7.0.0" - "@babel/plugin-transform-computed-properties" "^7.0.0" - "@babel/plugin-transform-destructuring" "^7.0.0" - "@babel/plugin-transform-flow-strip-types" "^7.0.0" - "@babel/plugin-transform-for-of" "^7.0.0" - "@babel/plugin-transform-function-name" "^7.0.0" - "@babel/plugin-transform-literals" "^7.0.0" - "@babel/plugin-transform-member-expression-literals" "^7.0.0" - "@babel/plugin-transform-modules-commonjs" "^7.0.0" - "@babel/plugin-transform-object-super" "^7.0.0" - "@babel/plugin-transform-parameters" "^7.0.0" - "@babel/plugin-transform-property-literals" "^7.0.0" - "@babel/plugin-transform-react-display-name" "^7.0.0" - "@babel/plugin-transform-react-jsx" "^7.0.0" - "@babel/plugin-transform-shorthand-properties" "^7.0.0" - "@babel/plugin-transform-spread" "^7.0.0" - "@babel/plugin-transform-template-literals" "^7.0.0" - babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -bl@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browserslist@^4.21.3: - version "4.21.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" - integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== - dependencies: - caniuse-lite "^1.0.30001449" - electron-to-chromium "^1.4.284" - node-releases "^2.0.8" - update-browserslist-db "^1.0.10" - -bser@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" - integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== - dependencies: - node-int64 "^0.4.0" - -buffer-crc32@^0.2.5: - version "0.2.13" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" - integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== - -buffer-equal-constant-time@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== - -buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -builtin-modules@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" - integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== - -busboy@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" - integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== - dependencies: - streamsearch "^1.1.0" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camel-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" - integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== - dependencies: - pascal-case "^3.1.2" - tslib "^2.0.3" - -camelcase-keys@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" - integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg== - dependencies: - camelcase "^5.3.1" - map-obj "^4.0.0" - quick-lru "^4.0.1" - -camelcase@^5.0.0, camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -caniuse-lite@^1.0.30001449: - version "1.0.30001458" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz#871e35866b4654a7d25eccca86864f411825540c" - integrity sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w== - -capital-case@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669" - integrity sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case-first "^2.0.2" - -chai@^4.3.7: - version "4.3.7" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.7.tgz#ec63f6df01829088e8bf55fca839bcd464a8ec51" - integrity sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A== - dependencies: - assertion-error "^1.1.0" - check-error "^1.0.2" - deep-eql "^4.1.2" - get-func-name "^2.0.0" - loupe "^2.3.1" - pathval "^1.1.1" - type-detect "^4.0.5" - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -change-case-all@1.0.15: - version "1.0.15" - resolved "https://registry.yarnpkg.com/change-case-all/-/change-case-all-1.0.15.tgz#de29393167fc101d646cd76b0ef23e27d09756ad" - integrity sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ== - dependencies: - change-case "^4.1.2" - is-lower-case "^2.0.2" - is-upper-case "^2.0.2" - lower-case "^2.0.2" - lower-case-first "^2.0.2" - sponge-case "^1.0.1" - swap-case "^2.0.2" - title-case "^3.0.3" - upper-case "^2.0.2" - upper-case-first "^2.0.2" - -change-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" - integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== - dependencies: - camel-case "^4.1.2" - capital-case "^1.0.4" - constant-case "^3.0.4" - dot-case "^3.0.4" - header-case "^2.0.4" - no-case "^3.0.4" - param-case "^3.0.4" - pascal-case "^3.1.2" - path-case "^3.0.4" - sentence-case "^3.0.4" - snake-case "^3.0.4" - tslib "^2.0.3" - -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - -check-error@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" - integrity sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA== - -"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-spinners@^2.5.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.7.0.tgz#f815fd30b5f9eaac02db604c7a231ed7cb2f797a" - integrity sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw== - -cli-truncate@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" - integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== - dependencies: - slice-ansi "^3.0.0" - string-width "^4.2.0" - -cli-truncate@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-3.1.0.tgz#3f23ab12535e3d73e839bb43e73c9de487db1389" - integrity sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA== - dependencies: - slice-ansi "^5.0.0" - string-width "^5.0.0" - -cli-width@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" - integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== - -cliui@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" - integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^6.2.0" - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - -clone@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" - integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -colorette@^2.0.16, colorette@^2.0.19: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -commander@^9.4.1: - version "9.5.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" - integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== - -commitlint@^17.4.3: - version "17.4.4" - resolved "https://registry.yarnpkg.com/commitlint/-/commitlint-17.4.4.tgz#884031ff5be10cc7a508f6f214e95543f9a2e020" - integrity sha512-trjD7/aJ3FyCMNRhP27QorPjvlE9m0AIlLKcusS6r8aDaDJQ8/MQMmANMv3LvjVx1SKy1MTSF0/oUw3T3If/EA== - dependencies: - "@commitlint/cli" "^17.4.4" - "@commitlint/types" "^17.4.4" - -common-tags@1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" - integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - -compare-func@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-2.0.0.tgz#fb65e75edbddfd2e568554e8b5b05fff7a51fcb3" - integrity sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA== - dependencies: - array-ify "^1.0.0" - dot-prop "^5.1.0" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -concurrently@^7.6.0: - version "7.6.0" - resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-7.6.0.tgz#531a6f5f30cf616f355a4afb8f8fcb2bba65a49a" - integrity sha512-BKtRgvcJGeZ4XttiDiNcFiRlxoAeZOseqUvyYRUp/Vtd+9p1ULmeoSqGsDA+2ivdeDFpqrJvGvmI+StKfKl5hw== - dependencies: - chalk "^4.1.0" - date-fns "^2.29.1" - lodash "^4.17.21" - rxjs "^7.0.0" - shell-quote "^1.7.3" - spawn-command "^0.0.2-1" - supports-color "^8.1.0" - tree-kill "^1.2.2" - yargs "^17.3.1" - -constant-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" - integrity sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case "^2.0.2" - -conventional-changelog-angular@^5.0.11: - version "5.0.13" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz#896885d63b914a70d4934b59d2fe7bde1832b28c" - integrity sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA== - dependencies: - compare-func "^2.0.0" - q "^1.5.1" - -conventional-changelog-conventionalcommits@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-5.0.0.tgz#41bdce54eb65a848a4a3ffdca93e92fa22b64a86" - integrity sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw== - dependencies: - compare-func "^2.0.0" - lodash "^4.17.15" - q "^1.5.1" - -conventional-commits-parser@^3.2.2: - version "3.2.4" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-3.2.4.tgz#a7d3b77758a202a9b2293d2112a8d8052c740972" - integrity sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q== - dependencies: - JSONStream "^1.0.4" - is-text-path "^1.0.1" - lodash "^4.17.15" - meow "^8.0.0" - split2 "^3.0.0" - through2 "^4.0.0" - -convert-source-map@^1.7.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" - integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== - -cookie@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - -cosmiconfig-typescript-loader@^4.0.0, cosmiconfig-typescript-loader@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz#c4259ce474c9df0f32274ed162c0447c951ef073" - integrity sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q== - -cosmiconfig@8.0.0, cosmiconfig@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.0.0.tgz#e9feae014eab580f858f8a0288f38997a7bebe97" - integrity sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ== - dependencies: - import-fresh "^3.2.1" - js-yaml "^4.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - -cosmiconfig@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" - integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.2.1" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.10.0" - -create-require@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" - integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== - -cross-fetch@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - -cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -dargs@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc" - integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== - -dataloader@2.2.2, dataloader@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.2.2.tgz#216dc509b5abe39d43a9b9d97e6e5e473dfbe3e0" - integrity sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g== - -date-fns@^2.29.1: - version "2.29.3" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" - integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== - -debounce@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" - integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== - -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decamelize-keys@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" - integrity sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg== - dependencies: - decamelize "^1.1.0" - map-obj "^1.0.0" - -decamelize@^1.1.0, decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -dedent-js@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dedent-js/-/dedent-js-1.0.1.tgz#bee5fb7c9e727d85dffa24590d10ec1ab1255305" - integrity sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ== - -deep-eql@^4.1.2: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== - dependencies: - type-detect "^4.0.0" - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.2: - version "4.3.0" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b" - integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og== - -defaults@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" - integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== - dependencies: - clone "^1.0.2" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -dependency-graph@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" - integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== - -detect-indent@^6.0.0, detect-indent@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6" - integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== - -devalue@^4.2.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.0.tgz#d86db8fee63a70317c2355be0d3d1b4d8f89a44e" - integrity sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA== - -diff@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dot-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" - integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -dot-prop@^5.1.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - dependencies: - is-obj "^2.0.0" - -dotenv@^16.0.0: - version "16.0.3" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" - integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== - -dset@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" - integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== - -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - -ecdsa-sig-formatter@1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" - integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== - dependencies: - safe-buffer "^5.0.1" - -electron-to-chromium@^1.4.284: - version "1.4.315" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.315.tgz#b60a6676b3a1db332cfc8919118344aa06b9ac99" - integrity sha512-ndBQYz3Eyy3rASjjQ9poMJGoAlsZ/aZnq6GBsGL4w/4sWIAwiUHVSsMuADbxa8WJw7pZ0oxLpGbtoDt4vRTdCg== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es6-promise@^3.1.2: - version "3.3.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" - integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg== - -esbuild@^0.16.14: - version "0.16.17" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.16.17.tgz#fc2c3914c57ee750635fee71b89f615f25065259" - integrity sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg== - optionalDependencies: - "@esbuild/android-arm" "0.16.17" - "@esbuild/android-arm64" "0.16.17" - "@esbuild/android-x64" "0.16.17" - "@esbuild/darwin-arm64" "0.16.17" - "@esbuild/darwin-x64" "0.16.17" - "@esbuild/freebsd-arm64" "0.16.17" - "@esbuild/freebsd-x64" "0.16.17" - "@esbuild/linux-arm" "0.16.17" - "@esbuild/linux-arm64" "0.16.17" - "@esbuild/linux-ia32" "0.16.17" - "@esbuild/linux-loong64" "0.16.17" - "@esbuild/linux-mips64el" "0.16.17" - "@esbuild/linux-ppc64" "0.16.17" - "@esbuild/linux-riscv64" "0.16.17" - "@esbuild/linux-s390x" "0.16.17" - "@esbuild/linux-x64" "0.16.17" - "@esbuild/netbsd-x64" "0.16.17" - "@esbuild/openbsd-x64" "0.16.17" - "@esbuild/sunos-x64" "0.16.17" - "@esbuild/win32-arm64" "0.16.17" - "@esbuild/win32-ia32" "0.16.17" - "@esbuild/win32-x64" "0.16.17" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -eslint-config-prettier@^8.5.0: - version "8.6.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz#dec1d29ab728f4fa63061774e1672ac4e363d207" - integrity sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA== - -eslint-plugin-svelte3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-svelte3/-/eslint-plugin-svelte3-4.0.0.tgz#3d4f3dcaec5761dac8bc697f81de3613b485b4e3" - integrity sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g== - -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -eslint-scope@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" - integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - -eslint-visitor-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" - integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== - -eslint-visitor-keys@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" - integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== - -eslint@^8.28.0: - version "8.34.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.34.0.tgz#fe0ab0ef478104c1f9ebc5537e303d25a8fb22d6" - integrity sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg== - dependencies: - "@eslint/eslintrc" "^1.4.1" - "@humanwhocodes/config-array" "^0.11.8" - "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.1.1" - eslint-utils "^3.0.0" - eslint-visitor-keys "^3.3.0" - espree "^9.4.0" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - glob-parent "^6.0.2" - globals "^13.19.0" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-sdsl "^4.1.4" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.1" - regexpp "^3.2.0" - strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" - text-table "^0.2.0" - -esm-env@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.0.0.tgz#b124b40b180711690a4cb9b00d16573391950413" - integrity sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA== - -espree@^9.4.0: - version "9.4.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.1.tgz#51d6092615567a2c2cff7833445e37c28c0065bd" - integrity sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg== - dependencies: - acorn "^8.8.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.3.0" - -esquery@^1.4.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.2.tgz#c6d3fee05dd665808e2ad870631f221f5617b1d1" - integrity sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -estree-walker@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" - integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -execa@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20" - integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.1" - human-signals "^3.0.1" - is-stream "^3.0.0" - merge-stream "^2.0.0" - npm-run-path "^5.1.0" - onetime "^6.0.0" - signal-exit "^3.0.7" - strip-final-newline "^3.0.0" - -external-editor@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - -extract-files@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-11.0.0.tgz#b72d428712f787eef1f5193aff8ab5351ca8469a" - integrity sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ== - -extract-files@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-9.0.0.tgz#8a7744f2437f81f5ed3250ed9f1550de902fe54a" - integrity sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ== - -fast-decode-uri-component@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" - integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-glob@^3.2.12, fast-glob@^3.2.7, fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fast-querystring@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/fast-querystring/-/fast-querystring-1.1.1.tgz#f4c56ef56b1a954880cfd8c01b83f9e1a3d3fda2" - integrity sha512-qR2r+e3HvhEFmpdHMv//U8FnFlnYjaC6QKDuaXALDkw2kvHO8WDjxH+f/rHGR4Me4pnk8p9JAkRNTjYHAKRn2Q== - dependencies: - fast-decode-uri-component "^1.0.1" - -fast-url-parser@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" - integrity sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ== - dependencies: - punycode "^1.3.2" - -fastq@^1.6.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" - integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== - dependencies: - reusify "^1.0.4" - -fb-watchman@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" - integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== - dependencies: - bser "2.1.1" - -fbjs-css-vars@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" - integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== - -fbjs@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.4.tgz#e1871c6bd3083bac71ff2da868ad5067d37716c6" - integrity sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ== - dependencies: - cross-fetch "^3.1.5" - fbjs-css-vars "^1.0.0" - loose-envify "^1.0.0" - object-assign "^4.1.0" - promise "^7.1.1" - setimmediate "^1.0.5" - ua-parser-js "^0.7.30" - -figures@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" - integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== - dependencies: - escape-string-regexp "^1.0.5" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== - dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" - -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== - -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - -fs-extra@^11.0.0, fs-extra@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.0.tgz#5784b102104433bb0e090f48bfc4a30742c357ed" - integrity sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.1, get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-func-name@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" - integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig== - -get-stream@^6.0.0, get-stream@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -git-raw-commits@^2.0.0: - version "2.0.11" - resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-2.0.11.tgz#bc3576638071d18655e1cc60d7f524920008d723" - integrity sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A== - dependencies: - dargs "^7.0.0" - lodash "^4.17.15" - meow "^8.0.0" - split2 "^3.0.0" - through2 "^4.0.0" - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob@^7.1.1, glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^8.0.3: - version "8.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - -global-dirs@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg== - dependencies: - ini "^1.3.4" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.19.0: - version "13.20.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" - integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== - dependencies: - type-fest "^0.20.2" - -globalyzer@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" - integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== - -globby@^11.0.3, globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -globrex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" - integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== - -graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - -grapheme-splitter@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" - integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== - -graphql-config@^4.4.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/graphql-config/-/graphql-config-4.4.1.tgz#2b1b5215b38911c0b15ff9b2e878101c984802d6" - integrity sha512-B8wlvfBHZ5WnI4IiuQZRqql6s+CKz7S+xpUeTb28Z8nRBi8tH9ChEBgT5FnTyE05PUhHlrS2jK9ICJ4YBl9OtQ== - dependencies: - "@graphql-tools/graphql-file-loader" "^7.3.7" - "@graphql-tools/json-file-loader" "^7.3.7" - "@graphql-tools/load" "^7.5.5" - "@graphql-tools/merge" "^8.2.6" - "@graphql-tools/url-loader" "^7.9.7" - "@graphql-tools/utils" "^9.0.0" - cosmiconfig "8.0.0" - minimatch "4.2.1" - string-env-interpolation "1.0.1" - tslib "^2.4.0" - -graphql-request@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-5.2.0.tgz#a05fb54a517d91bb2d7aefa17ade4523dc5ebdca" - integrity sha512-pLhKIvnMyBERL0dtFI3medKqWOz/RhHdcgbZ+hMMIb32mEPa5MJSzS4AuXxfI4sRAu6JVVk5tvXuGfCWl9JYWQ== - dependencies: - "@graphql-typed-document-node/core" "^3.1.1" - cross-fetch "^3.1.5" - extract-files "^9.0.0" - form-data "^3.0.0" - -graphql-tag@^2.11.0: - version "2.12.6" - resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" - integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== - dependencies: - tslib "^2.1.0" - -graphql-ws@5.11.3, graphql-ws@^5.11.3: - version "5.11.3" - resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.11.3.tgz#eaf8e6baf669d167975cff13ad86abca4ecfe82f" - integrity sha512-fU8zwSgAX2noXAsuFiCZ8BtXeXZOzXyK5u1LloCdacsVth4skdBMPO74EG51lBoWSIZ8beUocdpV8+cQHBODnQ== - -graphql@^16.6.0: - version "16.6.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" - integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== - -hard-rejection@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" - integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -header-case@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063" - integrity sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q== - dependencies: - capital-case "^1.0.4" - tslib "^2.0.3" - -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== - -hosted-git-info@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" - integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== - dependencies: - lru-cache "^6.0.0" - -http-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" - integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== - dependencies: - "@tootallnate/once" "2" - agent-base "6" - debug "4" - -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -human-signals@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" - integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== - -husky@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" - integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== - -iconv-lite@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -ignore@^5.2.0: - version "5.2.4" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" - integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== - -immutable@^4.0.0: - version "4.2.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.2.4.tgz#83260d50889526b4b531a5e293709a77f7c55a2a" - integrity sha512-WDxL3Hheb1JkRN3sQkyujNlL/xRjAo3rJtaU5xeufUauG66JdMr32bLj4gF+vWl84DIA3Zxw7tiAjneYzRRw+w== - -immutable@~3.7.6: - version "3.7.6" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" - integrity sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw== - -import-fresh@^3.0.0, import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -import-from@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/import-from/-/import-from-4.0.0.tgz#2710b8d66817d232e16f4166e319248d3d5492e2" - integrity sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ== - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3, inherits@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@^1.3.4: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -inquirer@^8.0.0: - version "8.2.5" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.5.tgz#d8654a7542c35a9b9e069d27e2df4858784d54f8" - integrity sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ== - dependencies: - ansi-escapes "^4.2.1" - chalk "^4.1.1" - cli-cursor "^3.1.0" - cli-width "^3.0.0" - external-editor "^3.0.3" - figures "^3.0.0" - lodash "^4.17.21" - mute-stream "0.0.8" - ora "^5.4.1" - run-async "^2.4.0" - rxjs "^7.5.5" - string-width "^4.1.0" - strip-ansi "^6.0.0" - through "^2.3.6" - wrap-ansi "^7.0.0" - -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -is-absolute@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" - integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== - dependencies: - is-relative "^1.0.0" - is-windows "^1.0.1" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-builtin-module@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" - integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== - dependencies: - builtin-modules "^3.3.0" - -is-core-module@^2.5.0, is-core-module@^2.9.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" - integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== - dependencies: - has "^1.0.3" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-fullwidth-code-point@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" - integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== - -is-glob@4.0.3, is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-interactive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" - integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== - -is-lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a" - integrity sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ== - dependencies: - tslib "^2.0.3" - -is-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== - -is-reference@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" - integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== - dependencies: - "@types/estree" "*" - -is-relative@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" - integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== - dependencies: - is-unc-path "^1.0.0" - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" - integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== - -is-text-path@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-text-path/-/is-text-path-1.0.1.tgz#4e1aa0fb51bfbcb3e92688001397202c1775b66e" - integrity sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w== - dependencies: - text-extensions "^1.0.0" - -is-unc-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" - integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== - dependencies: - unc-path-regex "^0.1.2" - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - -is-upper-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" - integrity sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ== - dependencies: - tslib "^2.0.3" - -is-windows@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isomorphic-fetch@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4" - integrity sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA== - dependencies: - node-fetch "^2.6.1" - whatwg-fetch "^3.4.1" - -isomorphic-ws@5.0.0, isomorphic-ws@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf" - integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw== - -js-sdsl@^4.1.4: - version "4.3.0" - resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.3.0.tgz#aeefe32a451f7af88425b11fdb5f58c90ae1d711" - integrity sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ== - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^4.0.0, js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json-stable-stringify@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.2.tgz#e06f23128e0bbe342dc996ed5a19e28b57b580e0" - integrity sha512-eunSSaEnxV12z+Z73y/j5N37/In40GK4GmsSy+tEHJMxknvqnA7/djeYtAgW0GsWHUfg+847WJjKaEylk2y09g== - dependencies: - jsonify "^0.0.1" - -json-to-pretty-yaml@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz#f4cd0bd0a5e8fe1df25aaf5ba118b099fd992d5b" - integrity sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A== - dependencies: - remedial "^1.0.7" - remove-trailing-spaces "^1.0.6" - -json5@^2.2.2: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonify@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" - integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== - -jsonparse@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== - -jsonwebtoken@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" - integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== - dependencies: - jws "^3.2.2" - lodash "^4.17.21" - ms "^2.1.1" - semver "^7.3.8" - -jwa@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" - integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== - dependencies: - buffer-equal-constant-time "1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== - dependencies: - jwa "^1.4.1" - safe-buffer "^5.0.1" - -kind-of@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -kleur@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" - integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -lilconfig@2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -lint-staged@^13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.1.2.tgz#443636a0cfd834d5518d57d228130dc04c83d6fb" - integrity sha512-K9b4FPbWkpnupvK3WXZLbgu9pchUJ6N7TtVZjbaPsoizkqFUDkUReUL25xdrCljJs7uLUF3tZ7nVPeo/6lp+6w== - dependencies: - cli-truncate "^3.1.0" - colorette "^2.0.19" - commander "^9.4.1" - debug "^4.3.4" - execa "^6.1.0" - lilconfig "2.0.6" - listr2 "^5.0.5" - micromatch "^4.0.5" - normalize-path "^3.0.0" - object-inspect "^1.12.2" - pidtree "^0.6.0" - string-argv "^0.3.1" - yaml "^2.1.3" - -listr2@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/listr2/-/listr2-4.0.5.tgz#9dcc50221583e8b4c71c43f9c7dfd0ef546b75d5" - integrity sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA== - dependencies: - cli-truncate "^2.1.0" - colorette "^2.0.16" - log-update "^4.0.0" - p-map "^4.0.0" - rfdc "^1.3.0" - rxjs "^7.5.5" - through "^2.3.8" - wrap-ansi "^7.0.0" - -listr2@^5.0.5: - version "5.0.7" - resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.7.tgz#de69ccc4caf6bea7da03c74f7a2ffecf3904bd53" - integrity sha512-MD+qXHPmtivrHIDRwPYdfNkrzqDiuaKU/rfBcec3WMyMF3xylQj3jMq344OtvQxz7zaCFViRAeqlr2AFhPvXHw== - dependencies: - cli-truncate "^2.1.0" - colorette "^2.0.19" - log-update "^4.0.0" - p-map "^4.0.0" - rfdc "^1.3.0" - rxjs "^7.8.0" - through "^2.3.8" - wrap-ansi "^7.0.0" - -local-pkg@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" - integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash.camelcase@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" - integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== - -lodash.isfunction@^3.0.9: - version "3.0.9" - resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" - integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw== - -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" - integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== - -lodash.kebabcase@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" - integrity sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g== - -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -lodash.mergewith@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" - integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== - -lodash.snakecase@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" - integrity sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw== - -lodash.sortby@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" - integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== - -lodash.startcase@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz#9436e34ed26093ed7ffae1936144350915d9add8" - integrity sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg== - -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== - -lodash.upperfirst@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" - integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== - -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17.0: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -log-symbols@^4.0.0, log-symbols@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -log-update@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" - integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== - dependencies: - ansi-escapes "^4.3.0" - cli-cursor "^3.1.0" - slice-ansi "^4.0.0" - wrap-ansi "^6.2.0" - -loose-envify@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -loupe@^2.3.1: - version "2.3.6" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" - integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA== - dependencies: - get-func-name "^2.0.0" - -lower-case-first@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case-first/-/lower-case-first-2.0.2.tgz#64c2324a2250bf7c37c5901e76a5b5309301160b" - integrity sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg== - dependencies: - tslib "^2.0.3" - -lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" - integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== - dependencies: - tslib "^2.0.3" - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -magic-string@^0.27.0: - version "0.27.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" - integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.13" - -magic-string@^0.29.0: - version "0.29.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.29.0.tgz#f034f79f8c43dba4ae1730ffb5e8c4e084b16cf3" - integrity sha512-WcfidHrDjMY+eLjlU+8OvwREqHwpgCeKVBUpQ3OhYYuvfaYCUgcbuBzappNzZvg/v8onU3oQj+BYpkOJe9Iw4Q== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.13" - -make-error@^1.1.1: - version "1.3.6" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - -map-cache@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== - -map-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - integrity sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg== - -map-obj@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" - integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== - -meow@^8.0.0: - version "8.1.2" - resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" - integrity sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q== - dependencies: - "@types/minimist" "^1.2.0" - camelcase-keys "^6.2.2" - decamelize-keys "^1.1.0" - hard-rejection "^2.1.0" - minimist-options "4.1.0" - normalize-package-data "^3.0.0" - read-pkg-up "^7.0.1" - redent "^3.0.0" - trim-newlines "^3.0.0" - type-fest "^0.18.0" - yargs-parser "^20.2.3" - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -meros@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/meros/-/meros-1.2.1.tgz#056f7a76e8571d0aaf3c7afcbe7eb6407ff7329e" - integrity sha512-R2f/jxYqCAGI19KhAvaxSOxALBMkaXWH2a7rOyqQw+ZmizX5bKkEYWLzdhC+U82ZVVPVp6MCXe3EkVligh+12g== - -micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -mimic-fn@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" - integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== - -min-indent@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" - integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== - -minimatch@4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" - integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimist-options@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" - integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A== - dependencies: - arrify "^1.0.1" - is-plain-obj "^1.1.0" - kind-of "^6.0.3" - -minimist@^1.2.0, minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -mkdirp@^0.5.1: - version "0.5.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== - dependencies: - minimist "^1.2.6" - -mri@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" - integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== - -mrmime@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" - integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -mute-stream@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== - -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -natural-compare-lite@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" - integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -no-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" - integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== - dependencies: - lower-case "^2.0.2" - tslib "^2.0.3" - -node-addon-api@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" - integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== - -node-fetch@2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - -node-fetch@^2.6.1: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== - dependencies: - whatwg-url "^5.0.0" - -node-gyp-build@^4.3.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" - integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== - -node-int64@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" - integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== - -node-releases@^2.0.8: - version "2.0.10" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" - integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== - -normalize-package-data@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-package-data@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e" - integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA== - dependencies: - hosted-git-info "^4.0.1" - is-core-module "^2.5.0" - semver "^7.3.4" - validate-npm-package-license "^3.0.1" - -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w== - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -npm-run-path@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00" - integrity sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q== - dependencies: - path-key "^4.0.0" - -nullthrows@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" - integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== - -object-assign@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.12.2: - version "1.12.3" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" - integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.0, onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -onetime@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" - integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== - dependencies: - mimic-fn "^4.0.0" - -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.3" - -ora@^5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" - integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== - dependencies: - bl "^4.1.0" - chalk "^4.1.0" - cli-cursor "^3.1.0" - cli-spinners "^2.5.0" - is-interactive "^1.0.0" - is-unicode-supported "^0.1.0" - log-symbols "^4.1.0" - strip-ansi "^6.0.0" - wcwidth "^1.0.1" - -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== - -p-limit@3.1.0, p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -param-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" - integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-filepath@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" - integrity sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q== - dependencies: - is-absolute "^1.0.0" - map-cache "^0.2.0" - path-root "^0.1.1" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -pascal-case@^3.1.1, pascal-case@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" - integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -path-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/path-case/-/path-case-3.0.4.tgz#9168645334eb942658375c56f80b4c0cb5f82c6f" - integrity sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-key@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" - integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-root-regex@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" - integrity sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ== - -path-root@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" - integrity sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg== - dependencies: - path-root-regex "^0.1.0" - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -pathval@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" - integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pidtree@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" - integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== - -playwright-core@1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.30.0.tgz#de987cea2e86669e3b85732d230c277771873285" - integrity sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g== - -postcss@^8.4.21: - version "8.4.21" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" - integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -prettier-plugin-svelte@^2.8.1: - version "2.9.0" - resolved "https://registry.yarnpkg.com/prettier-plugin-svelte/-/prettier-plugin-svelte-2.9.0.tgz#16cc7fa73fa96eaef48b44089753ac9f1f1175e5" - integrity sha512-3doBi5NO4IVgaNPtwewvrgPpqAcvNv0NwJNflr76PIGgi9nf1oguQV1Hpdm9TI2ALIQVn/9iIwLpBO5UcD2Jiw== - -prettier@^2.8.0, prettier@^2.8.4: - version "2.8.4" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3" - integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw== - -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== - dependencies: - asap "~2.0.3" - -punycode@^1.3.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== - -punycode@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== - -pvtsutils@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" - integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== - dependencies: - tslib "^2.4.0" - -pvutils@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" - integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== - -q@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" - integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" - integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== - -read-pkg-up@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" - integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== - dependencies: - find-up "^4.1.0" - read-pkg "^5.2.0" - type-fest "^0.8.1" - -read-pkg@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" - integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== - dependencies: - "@types/normalize-package-data" "^2.4.0" - normalize-package-data "^2.5.0" - parse-json "^5.0.0" - type-fest "^0.6.0" - -readable-stream@3, readable-stream@^3.0.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" - integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^3.4.0: - version "3.6.1" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.1.tgz#f9f9b5f536920253b3d26e7660e7da4ccff9bb62" - integrity sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -redent@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" - integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== - dependencies: - indent-string "^4.0.0" - strip-indent "^3.0.0" - -regenerator-runtime@^0.13.11: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - -regexpp@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" - integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== - -relay-runtime@12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-12.0.0.tgz#1e039282bdb5e0c1b9a7dc7f6b9a09d4f4ff8237" - integrity sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug== - dependencies: - "@babel/runtime" "^7.0.0" - fbjs "^3.0.0" - invariant "^2.2.4" - -remedial@^1.0.7: - version "1.0.8" - resolved "https://registry.yarnpkg.com/remedial/-/remedial-1.0.8.tgz#a5e4fd52a0e4956adbaf62da63a5a46a78c578a0" - integrity sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg== - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw== - -remove-trailing-spaces@^1.0.6: - version "1.0.8" - resolved "https://registry.yarnpkg.com/remove-trailing-spaces/-/remove-trailing-spaces-1.0.8.tgz#4354d22f3236374702f58ee373168f6d6887ada7" - integrity sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA== - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -resolve-from@5.0.0, resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-global@1.0.0, resolve-global@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/resolve-global/-/resolve-global-1.0.0.tgz#a2a79df4af2ca3f49bf77ef9ddacd322dad19255" - integrity sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw== - dependencies: - global-dirs "^0.1.1" - -resolve@^1.10.0, resolve@^1.22.1: - version "1.22.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" - integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== - dependencies: - is-core-module "^2.9.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rfdc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" - integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== - -rimraf@^2.5.2: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -rollup@^3.10.0, rollup@^3.12.0: - version "3.17.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.17.1.tgz#4be78852c2689d8ce5a21efbc2a45879bac75362" - integrity sha512-8RnSms6rNqHmZK+wiqgnPCqen+rRnUHXkciGDirh7B00g1rX1vpKbPDhuxCvAG2bburoI+W4Q9/PlUB/zYkiYA== - optionalDependencies: - fsevents "~2.3.2" - -run-async@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" - integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -rxjs@^7.0.0, rxjs@^7.5.5, rxjs@^7.8.0: - version "7.8.0" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" - integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== - dependencies: - tslib "^2.1.0" - -sade@^1.7.4, sade@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" - integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== - dependencies: - mri "^1.1.0" - -safe-buffer@^5.0.1, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sander@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/sander/-/sander-0.5.1.tgz#741e245e231f07cafb6fdf0f133adfa216a502ad" - integrity sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA== - dependencies: - es6-promise "^3.1.2" - graceful-fs "^4.1.3" - mkdirp "^0.5.1" - rimraf "^2.5.2" - -sass@^1.58.3: - version "1.58.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.58.3.tgz#2348cc052061ba4f00243a208b09c40e031f270d" - integrity sha512-Q7RaEtYf6BflYrQ+buPudKR26/lH+10EmO9bBqbmPh/KeLqv8bjpTNqxe71ocONqXq+jYiCbpPUmQMS+JJPk4A== - dependencies: - chokidar ">=3.0.0 <4.0.0" - immutable "^4.0.0" - source-map-js ">=0.6.2 <2.0.0" - -scuid@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/scuid/-/scuid-1.1.0.tgz#d3f9f920956e737a60f72d0e4ad280bf324d5dab" - integrity sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg== - -"semver@2 || 3 || 4 || 5": - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@7.3.8, semver@^7.3.4, semver@^7.3.7, semver@^7.3.8: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== - dependencies: - lru-cache "^6.0.0" - -semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -sentence-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/sentence-case/-/sentence-case-3.0.4.tgz#3645a7b8c117c787fde8702056225bb62a45131f" - integrity sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case-first "^2.0.2" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== - -set-cookie-parser@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz#ddd3e9a566b0e8e0862aca974a6ac0e01349430b" - integrity sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ== - -setimmediate@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -shell-quote@^1.7.3: - version "1.8.0" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.0.tgz#20d078d0eaf71d54f43bd2ba14a1b5b9bfa5c8ba" - integrity sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ== - -signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -signedsource@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/signedsource/-/signedsource-1.0.0.tgz#1ddace4981798f93bd833973803d80d52e93ad6a" - integrity sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww== - -sirv@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.2.tgz#128b9a628d77568139cff85703ad5497c46a4760" - integrity sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w== - dependencies: - "@polka/url" "^1.0.0-next.20" - mrmime "^1.0.0" - totalist "^3.0.0" - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -slice-ansi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" - integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - -slice-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - -slice-ansi@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" - integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== - dependencies: - ansi-styles "^6.0.0" - is-fullwidth-code-point "^4.0.0" - -snake-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" - integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -sorcery@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/sorcery/-/sorcery-0.11.0.tgz#310c80ee993433854bb55bb9aa4003acd147fca8" - integrity sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.14" - buffer-crc32 "^0.2.5" - minimist "^1.2.0" - sander "^0.5.0" - -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -spawn-command@^0.0.2-1: - version "0.0.2-1" - resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" - integrity sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg== - -spdx-correct@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" - integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.12" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779" - integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA== - -split2@^3.0.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f" - integrity sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg== - dependencies: - readable-stream "^3.0.0" - -sponge-case@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c" - integrity sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA== - dependencies: - tslib "^2.0.3" - -streamsearch@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" - integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== - -string-argv@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" - integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== - -string-env-interpolation@1.0.1, string-env-interpolation@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152" - integrity sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg== - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" - integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== - dependencies: - ansi-regex "^6.0.1" - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-final-newline@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" - integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== - -strip-indent@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" - integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== - dependencies: - min-indent "^1.0.0" - -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -strip-literal@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.0.1.tgz#0115a332710c849b4e46497891fb8d585e404bd2" - integrity sha512-QZTsipNpa2Ppr6v1AmJHESqJ3Uz247MUS0OjrnnZjFAvEoWqxuyFuXn2xLgMtRnijJShAa1HL0gtJyUs7u7n3Q== - dependencies: - acorn "^8.8.2" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.1.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -svelte-adapter-deno@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/svelte-adapter-deno/-/svelte-adapter-deno-0.9.0.tgz#9f9e0e8a5670f24f5960b8f1812dbe025b8d7664" - integrity sha512-BIo0tb3BXp9kimM9NQYR9+xqUXKFgMzxS8+5Ly256MoHl8F3ENMFDF0yCCyiqtf3M/LuZwQKq7Ko4PhpQZMUAQ== - dependencies: - "@rollup/plugin-commonjs" "^24.0.1" - "@rollup/plugin-json" "^6.0.0" - "@rollup/plugin-node-resolve" "^15.0.1" - rollup "^3.12.0" - -svelte-check@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-3.0.3.tgz#7e89fe4d2adc43869983707822f7c4d7ede74505" - integrity sha512-ByBFXo3bfHRGIsYEasHkdMhLkNleVfszX/Ns1oip58tPJlKdo5Ssr8kgVIuo5oq00hss8AIcdesuy0Xt0BcTvg== - dependencies: - "@jridgewell/trace-mapping" "^0.3.17" - chokidar "^3.4.1" - fast-glob "^3.2.7" - import-fresh "^3.2.1" - picocolors "^1.0.0" - sade "^1.7.4" - svelte-preprocess "^5.0.0" - typescript "^4.9.4" - -svelte-fa@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-3.0.3.tgz#49aa8627725b2533fb4d109b39cd8e64f7e54b7c" - integrity sha512-GIikJjcVCD+5Y/x9hZc2R4gvuA0gVftacuWu1a+zVQWSFjFYZ+hhU825x+QNs2slsppfrgmFiUyU9Sz9gj4Rdw== - -svelte-hmr@^0.15.1: - version "0.15.1" - resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.15.1.tgz#d11d878a0bbb12ec1cba030f580cd2049f4ec86b" - integrity sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA== - -svelte-preprocess@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz#3dd21a17eb508347d4b26a0d98059d23e2d1b9a0" - integrity sha512-0HXyhCoc9rsW4zGOgtInylC6qj259E1hpFnJMJWTf+aIfeqh4O/QHT31KT2hvPEqQfdjmqBR/kO2JDkkciBLrQ== - dependencies: - "@types/pug" "^2.0.6" - "@types/sass" "^1.43.1" - detect-indent "^6.1.0" - magic-string "^0.27.0" - sorcery "^0.11.0" - strip-indent "^3.0.0" - -svelte-turnstile@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/svelte-turnstile/-/svelte-turnstile-0.3.1.tgz#13063b25a79230611f29b6fa3adcd1d08d83a8ff" - integrity sha512-b0A90zMMjm0U0S1zOP9R+msnG8+kBJNspBiLVzwaH7uOUmjFLt8ujArqlHWQhXj+bRZEMM2dXIYBykVDku7pXg== - -svelte2tsx@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/svelte2tsx/-/svelte2tsx-0.6.2.tgz#f4e4e0c85c78dbd73326cf93e2c9b4da1f04b236" - integrity sha512-0ircYY2/jMOfistf+iq8fVHERnu1i90nku56c78+btC8svyafsc3OjOV37LDEOV7buqYY1Rv/uy03eMxhopH2Q== - dependencies: - dedent-js "^1.0.1" - pascal-case "^3.1.1" - -svelte@^3.54.0: - version "3.55.1" - resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.55.1.tgz#6f93b153e5248039906ce5fe196efdb9e05dfce8" - integrity sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ== - -swap-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/swap-case/-/swap-case-2.0.2.tgz#671aedb3c9c137e2985ef51c51f9e98445bf70d9" - integrity sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw== - dependencies: - tslib "^2.0.3" - -text-extensions@^1.0.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.9.0.tgz#1853e45fee39c945ce6f6c36b2d659b5aabc2a26" - integrity sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ== - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -through2@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/through2/-/through2-4.0.2.tgz#a7ce3ac2a7a8b0b966c80e7c49f0484c3b239764" - integrity sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw== - dependencies: - readable-stream "3" - -"through@>=2.2.7 <3", through@^2.3.6, through@^2.3.8: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - -tiny-glob@^0.2.9: - version "0.2.9" - resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" - integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== - dependencies: - globalyzer "0.1.0" - globrex "^0.1.2" - -tinybench@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.3.1.tgz#14f64e6b77d7ef0b1f6ab850c7a808c6760b414d" - integrity sha512-hGYWYBMPr7p4g5IarQE7XhlyWveh1EKhy4wUBS1LrHXCKYgvz+4/jCqgmJqZxxldesn05vccrtME2RLLZNW7iA== - -tinypool@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.3.1.tgz#a99c2e446aba9be05d3e1cb756d6aed7af4723b6" - integrity sha512-zLA1ZXlstbU2rlpA4CIeVaqvWq41MTWqLY3FfsAXgC8+f7Pk7zroaJQxDgxn1xNudKW6Kmj4808rPFShUlIRmQ== - -tinyspy@^1.0.2: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-1.1.1.tgz#0cb91d5157892af38cb2d217f5c7e8507a5bf092" - integrity sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g== - -title-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" - integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== - dependencies: - tslib "^2.0.3" - -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -totalist@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.0.tgz#4ef9c58c5f095255cdc3ff2a0a55091c57a3a1bd" - integrity sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw== - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -tree-kill@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" - integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== - -trim-newlines@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" - integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== - -ts-log@^2.2.3: - version "2.2.5" - resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.2.5.tgz#aef3252f1143d11047e2cb6f7cfaac7408d96623" - integrity sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA== - -ts-node@^10.8.1, ts-node@^10.9.1: - version "10.9.1" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" - integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== - dependencies: - "@cspotcode/source-map-support" "^0.8.0" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - v8-compile-cache-lib "^3.0.1" - yn "3.1.1" - -tslib@^1.8.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" - integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== - -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== - -tsutils@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-detect@^4.0.0, type-detect@^4.0.5: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - -type-fest@^0.18.0: - version "0.18.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" - integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - -type-fest@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" - integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== - -type-fest@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== - -typescript@^4.6.4, typescript@^4.9.4, typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - -ua-parser-js@^0.7.30: - version "0.7.33" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" - integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw== - -unc-path-regex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" - integrity sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg== - -undici@5.19.1: - version "5.19.1" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.19.1.tgz#92b1fd3ab2c089b5a6bd3e579dcda8f1934ebf6d" - integrity sha512-YiZ61LPIgY73E7syxCDxxa3LV2yl3sN8spnIuTct60boiiRaE1J8mNWHO8Im2Zi/sFrPusjLlmRPrsyraSqX6A== - dependencies: - busboy "^1.6.0" - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -unixify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" - integrity sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg== - dependencies: - normalize-path "^2.1.1" - -update-browserslist-db@^1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" - integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -upper-case-first@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324" - integrity sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg== - dependencies: - tslib "^2.0.3" - -upper-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-2.0.2.tgz#d89810823faab1df1549b7d97a76f8662bae6f7a" - integrity sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg== - dependencies: - tslib "^2.0.3" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -urlpattern-polyfill@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-6.0.2.tgz#a193fe773459865a2a5c93b246bb794b13d07256" - integrity sha512-5vZjFlH9ofROmuWmXM9yj2wljYKgWstGwe8YTyiqM7hVum/g9LyCizPZtb3UqsuppVwety9QJmfc42VggLpTgg== - dependencies: - braces "^3.0.2" - -urql@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/urql/-/urql-3.0.3.tgz#275631f487558354e090d9ffc4ea2030bd56c34a" - integrity sha512-aVUAMRLdc5AOk239DxgXt6ZxTl/fEmjr7oyU5OGo8uvpqu42FkeJErzd2qBzhAQ3DyusoZIbqbBLPlnKo/yy2A== - dependencies: - "@urql/core" "^3.0.3" - wonka "^6.0.0" - -util-deprecate@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -v8-compile-cache-lib@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" - integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -value-or-promise@1.0.12, value-or-promise@^1.0.11, value-or-promise@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.12.tgz#0e5abfeec70148c78460a849f6b003ea7986f15c" - integrity sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q== - -"vite@^3.0.0 || ^4.0.0", vite@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.1.2.tgz#6908882984e490c44c28e784a2c52de556f04b41" - integrity sha512-MWDb9Rfy3DI8omDQySbMK93nQqStwbsQWejXRY2EBzEWKmLAXWb1mkI9Yw2IJrc+oCvPCI1Os5xSSIBYY6DEAw== - dependencies: - esbuild "^0.16.14" - postcss "^8.4.21" - resolve "^1.22.1" - rollup "^3.10.0" - optionalDependencies: - fsevents "~2.3.2" - -vitefu@^0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.4.tgz#212dc1a9d0254afe65e579351bed4e25d81e0b35" - integrity sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g== - -vitest@^0.25.3: - version "0.25.8" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.25.8.tgz#9b57e0b41cd6f2d2d92aa94a39b35c36f715f8cc" - integrity sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg== - dependencies: - "@types/chai" "^4.3.4" - "@types/chai-subset" "^1.3.3" - "@types/node" "*" - acorn "^8.8.1" - acorn-walk "^8.2.0" - chai "^4.3.7" - debug "^4.3.4" - local-pkg "^0.4.2" - source-map "^0.6.1" - strip-literal "^1.0.0" - tinybench "^2.3.1" - tinypool "^0.3.0" - tinyspy "^1.0.2" - vite "^3.0.0 || ^4.0.0" - -wcwidth@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" - integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== - dependencies: - defaults "^1.0.3" - -web-streams-polyfill@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" - integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== - -webcrypto-core@^1.7.4: - version "1.7.6" - resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.7.6.tgz#e32c4a12a13de4251f8f9ef336a6cba7cdec9b55" - integrity sha512-TBPiewB4Buw+HI3EQW+Bexm19/W4cP/qZG/02QJCXN+iN+T5sl074vZ3rJcle/ZtDBQSgjkbsQO/1eFcxnSBUA== - dependencies: - "@peculiar/asn1-schema" "^2.1.6" - "@peculiar/json-schema" "^1.1.12" - asn1js "^3.0.1" - pvtsutils "^1.3.2" - tslib "^2.4.0" - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -whatwg-fetch@^3.4.1: - version "3.6.2" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" - integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wonka@^6.0.0, wonka@^6.1.2, wonka@^6.2.3: - version "6.2.3" - resolved "https://registry.yarnpkg.com/wonka/-/wonka-6.2.3.tgz#88f7852a23a3d53bca7411c70d66e9ce8f93a366" - integrity sha512-EFOYiqDeYLXSzGYt2X3aVe9Hq1XJG+Hz/HjTRRT4dZE9q95khHl5+7pzUSXI19dbMO1/2UMrTf7JT7/7JrSQSQ== - -word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -ws@8.12.1, ws@^8.12.0: - version "8.12.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.1.tgz#c51e583d79140b5e42e39be48c934131942d4a8f" - integrity sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew== - -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml-ast-parser@^0.0.43: - version "0.0.43" - resolved "https://registry.yarnpkg.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" - integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== - -yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yaml@^2.1.3: - version "2.2.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" - integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== - -yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^20.2.3: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^15.3.1: - version "15.4.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" - integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== - dependencies: - cliui "^6.0.0" - decamelize "^1.2.0" - find-up "^4.1.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^4.2.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^18.1.2" - -yargs@^17.0.0, yargs@^17.3.1: - version "17.7.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.0.tgz#b21e9af1e0a619a2a9c67b1133219b2975a07985" - integrity sha512-dwqOPg5trmrre9+v8SUo2q/hAwyKoVfu8OC1xPHKJGNdxAvPl4sKxL4vBnh3bQz/ZvvGAFeA5H3ou2kcOY8sQQ== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yn@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -zod@^3.20.6: - version "3.20.6" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.6.tgz#2f2f08ff81291d47d99e86140fedb4e0db08361a" - integrity sha512-oyu0m54SGCtzh6EClBVqDDlAYRz4jrVtKwQ7ZnsEmMI9HnzuZFj8QFwAY1M5uniIYACdGvv0PBWPF2kO0aNofA==