this repo has no description

init

mmatt.net df928ba5

+4655
+10
.dockerignore
···
··· 1 + .git 2 + .github 3 + target 4 + docker-compose*.yml 5 + *.log 6 + .env 7 + .env.* 8 + README.md 9 + scripts 10 + tests
+21
.env.example
···
··· 1 + WEBSITE_URL=https://example.com 2 + 3 + # Option A: full output URL 4 + OUTPUT=rtmp://live.example.com/app/mystream 5 + 6 + # Option B: split URL + key (used when OUTPUT is empty) 7 + RTMP_URL=rtmp://live.example.com/app 8 + STREAM_KEY=mystream 9 + 10 + WIDTH=1920 11 + HEIGHT=1080 12 + FPS=30 13 + BITRATE_KBPS=4500 14 + KEYINT_SEC=1 15 + X264_OPTS=bframes=0 16 + RETRIES=5 17 + RETRY_BACKOFF_MS=1000 18 + STARTUP_DELAY_MS=2000 19 + FRAME_TIMEOUT_MS=30000 20 + NO_AUDIO=0 21 + VERBOSE=0
+109
.github/workflows/build-and-release.yml
···
··· 1 + name: Build and Release 2 + 3 + on: 4 + pull_request: 5 + push: 6 + branches: 7 + - main 8 + tags: 9 + - "v*" 10 + workflow_dispatch: 11 + 12 + permissions: 13 + contents: write 14 + 15 + jobs: 16 + build: 17 + name: ${{ matrix.name }} 18 + runs-on: ${{ matrix.os }} 19 + strategy: 20 + fail-fast: false 21 + matrix: 22 + include: 23 + - name: macOS arm64 24 + os: macos-14 25 + target: aarch64-apple-darwin 26 + artifact_id: macos-arm64 27 + archive_ext: tar.gz 28 + bin_name: browser-stream 29 + - name: Linux x86_64 30 + os: ubuntu-24.04 31 + target: x86_64-unknown-linux-gnu 32 + artifact_id: linux-x86_64 33 + archive_ext: tar.gz 34 + bin_name: browser-stream 35 + - name: Windows x86_64 36 + os: windows-2022 37 + target: x86_64-pc-windows-msvc 38 + artifact_id: windows-x86_64 39 + archive_ext: zip 40 + bin_name: browser-stream.exe 41 + 42 + steps: 43 + - name: Checkout 44 + uses: actions/checkout@v4 45 + 46 + - name: Setup Rust 47 + uses: dtolnay/rust-toolchain@stable 48 + with: 49 + targets: ${{ matrix.target }} 50 + 51 + - name: Build release binary 52 + run: cargo build --release --target ${{ matrix.target }} 53 + 54 + - name: Install sidecar tooling (Linux) 55 + if: runner.os == 'Linux' 56 + run: | 57 + sudo apt-get update 58 + sudo apt-get install -y jq unzip 59 + 60 + - name: Install sidecar tooling (macOS) 61 + if: runner.os == 'macOS' 62 + run: brew install jq unzip 63 + 64 + - name: Fetch sidecars (macOS/Linux) 65 + if: runner.os != 'Windows' 66 + run: ./scripts/fetch-sidecars.sh --destination dist/sidecar 67 + 68 + - name: Fetch sidecars (Windows) 69 + if: runner.os == 'Windows' 70 + shell: pwsh 71 + run: ./scripts/fetch-sidecars.ps1 -Destination dist/sidecar 72 + 73 + - name: Package archive (macOS/Linux) 74 + if: runner.os != 'Windows' 75 + run: | 76 + set -euo pipefail 77 + ARCHIVE_ROOT="browser-stream-${{ matrix.artifact_id }}" 78 + PACKAGE_ROOT="dist/${ARCHIVE_ROOT}" 79 + 80 + mkdir -p "$PACKAGE_ROOT/bin" "$PACKAGE_ROOT/sidecar" 81 + cp "target/${{ matrix.target }}/release/${{ matrix.bin_name }}" "$PACKAGE_ROOT/bin/${{ matrix.bin_name }}" 82 + cp -R dist/sidecar/. "$PACKAGE_ROOT/sidecar/" 83 + 84 + tar -czf "dist/${ARCHIVE_ROOT}.tar.gz" -C dist "${ARCHIVE_ROOT}" 85 + 86 + - name: Package archive (Windows) 87 + if: runner.os == 'Windows' 88 + shell: pwsh 89 + run: | 90 + $archiveRoot = "browser-stream-${{ matrix.artifact_id }}" 91 + $packageRoot = "dist/$archiveRoot" 92 + 93 + New-Item -ItemType Directory -Force -Path "$packageRoot/bin" | Out-Null 94 + Copy-Item "target/${{ matrix.target }}/release/${{ matrix.bin_name }}" "$packageRoot/bin/${{ matrix.bin_name }}" -Force 95 + Copy-Item "dist/sidecar" "$packageRoot/sidecar" -Recurse -Force 96 + 97 + Compress-Archive -Path $packageRoot -DestinationPath "dist/$archiveRoot.zip" -Force 98 + 99 + - name: Upload workflow artifact 100 + uses: actions/upload-artifact@v4 101 + with: 102 + name: browser-stream-${{ matrix.artifact_id }} 103 + path: dist/browser-stream-${{ matrix.artifact_id }}.${{ matrix.archive_ext }} 104 + 105 + - name: Publish release asset 106 + if: startsWith(github.ref, 'refs/tags/') 107 + uses: softprops/action-gh-release@v2 108 + with: 109 + files: dist/browser-stream-${{ matrix.artifact_id }}.${{ matrix.archive_ext }}
+71
.github/workflows/docker-publish.yml
···
··· 1 + name: Docker Publish 2 + 3 + on: 4 + pull_request: 5 + push: 6 + branches: 7 + - main 8 + tags: 9 + - "v*" 10 + workflow_dispatch: 11 + 12 + permissions: 13 + contents: read 14 + packages: write 15 + 16 + jobs: 17 + docker: 18 + name: Build and Publish GHCR Image (${{ matrix.variant }}) 19 + runs-on: ubuntu-latest 20 + strategy: 21 + fail-fast: false 22 + matrix: 23 + include: 24 + - variant: slim 25 + target: slim 26 + platforms: linux/amd64,linux/arm64 27 + - variant: full 28 + target: full 29 + platforms: linux/amd64 30 + 31 + steps: 32 + - name: Checkout 33 + uses: actions/checkout@v4 34 + 35 + - name: Set up QEMU 36 + uses: docker/setup-qemu-action@v3 37 + 38 + - name: Set up Docker Buildx 39 + uses: docker/setup-buildx-action@v3 40 + 41 + - name: Log in to GHCR 42 + if: github.event_name != 'pull_request' 43 + uses: docker/login-action@v3 44 + with: 45 + registry: ghcr.io 46 + username: ${{ github.actor }} 47 + password: ${{ secrets.GITHUB_TOKEN }} 48 + 49 + - name: Generate Docker metadata 50 + id: meta 51 + uses: docker/metadata-action@v5 52 + with: 53 + images: ghcr.io/${{ github.repository }} 54 + tags: | 55 + type=ref,event=branch,suffix=-${{ matrix.variant }} 56 + type=ref,event=tag,suffix=-${{ matrix.variant }} 57 + type=sha,prefix=sha-,suffix=-${{ matrix.variant }} 58 + type=raw,value=latest-${{ matrix.variant }},enable={{is_default_branch}} 59 + 60 + - name: Build and push 61 + uses: docker/build-push-action@v6 62 + with: 63 + context: . 64 + file: Dockerfile 65 + target: ${{ matrix.target }} 66 + platforms: ${{ matrix.platforms }} 67 + push: ${{ github.event_name != 'pull_request' }} 68 + tags: ${{ steps.meta.outputs.tags }} 69 + labels: ${{ steps.meta.outputs.labels }} 70 + cache-from: type=gha 71 + cache-to: type=gha,mode=max
+1
.gitignore
···
··· 1 + /target
+2522
Cargo.lock
···
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 12 + name = "aho-corasick" 13 + version = "1.1.4" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 16 + dependencies = [ 17 + "memchr", 18 + ] 19 + 20 + [[package]] 21 + name = "aligned" 22 + version = "0.4.3" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" 25 + dependencies = [ 26 + "as-slice", 27 + ] 28 + 29 + [[package]] 30 + name = "aligned-vec" 31 + version = "0.6.4" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" 34 + dependencies = [ 35 + "equator", 36 + ] 37 + 38 + [[package]] 39 + name = "anstream" 40 + version = "0.6.21" 41 + source = "registry+https://github.com/rust-lang/crates.io-index" 42 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 43 + dependencies = [ 44 + "anstyle", 45 + "anstyle-parse", 46 + "anstyle-query", 47 + "anstyle-wincon", 48 + "colorchoice", 49 + "is_terminal_polyfill", 50 + "utf8parse", 51 + ] 52 + 53 + [[package]] 54 + name = "anstyle" 55 + version = "1.0.13" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 58 + 59 + [[package]] 60 + name = "anstyle-parse" 61 + version = "0.2.7" 62 + source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 64 + dependencies = [ 65 + "utf8parse", 66 + ] 67 + 68 + [[package]] 69 + name = "anstyle-query" 70 + version = "1.1.5" 71 + source = "registry+https://github.com/rust-lang/crates.io-index" 72 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 73 + dependencies = [ 74 + "windows-sys 0.61.2", 75 + ] 76 + 77 + [[package]] 78 + name = "anstyle-wincon" 79 + version = "3.0.11" 80 + source = "registry+https://github.com/rust-lang/crates.io-index" 81 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 82 + dependencies = [ 83 + "anstyle", 84 + "once_cell_polyfill", 85 + "windows-sys 0.61.2", 86 + ] 87 + 88 + [[package]] 89 + name = "anyhow" 90 + version = "1.0.101" 91 + source = "registry+https://github.com/rust-lang/crates.io-index" 92 + checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" 93 + 94 + [[package]] 95 + name = "arbitrary" 96 + version = "1.4.2" 97 + source = "registry+https://github.com/rust-lang/crates.io-index" 98 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 99 + 100 + [[package]] 101 + name = "arg_enum_proc_macro" 102 + version = "0.3.4" 103 + source = "registry+https://github.com/rust-lang/crates.io-index" 104 + checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" 105 + dependencies = [ 106 + "proc-macro2", 107 + "quote", 108 + "syn", 109 + ] 110 + 111 + [[package]] 112 + name = "arrayvec" 113 + version = "0.7.6" 114 + source = "registry+https://github.com/rust-lang/crates.io-index" 115 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 116 + 117 + [[package]] 118 + name = "as-slice" 119 + version = "0.2.1" 120 + source = "registry+https://github.com/rust-lang/crates.io-index" 121 + checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" 122 + dependencies = [ 123 + "stable_deref_trait", 124 + ] 125 + 126 + [[package]] 127 + name = "assert_matches" 128 + version = "1.5.0" 129 + source = "registry+https://github.com/rust-lang/crates.io-index" 130 + checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" 131 + 132 + [[package]] 133 + name = "async-tungstenite" 134 + version = "0.32.1" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" 137 + dependencies = [ 138 + "atomic-waker", 139 + "futures-core", 140 + "futures-io", 141 + "futures-task", 142 + "futures-util", 143 + "log", 144 + "pin-project-lite", 145 + "tokio", 146 + "tungstenite", 147 + ] 148 + 149 + [[package]] 150 + name = "atomic-waker" 151 + version = "1.1.2" 152 + source = "registry+https://github.com/rust-lang/crates.io-index" 153 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 154 + 155 + [[package]] 156 + name = "autocfg" 157 + version = "1.5.0" 158 + source = "registry+https://github.com/rust-lang/crates.io-index" 159 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 160 + 161 + [[package]] 162 + name = "av-scenechange" 163 + version = "0.14.1" 164 + source = "registry+https://github.com/rust-lang/crates.io-index" 165 + checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" 166 + dependencies = [ 167 + "aligned", 168 + "anyhow", 169 + "arg_enum_proc_macro", 170 + "arrayvec", 171 + "log", 172 + "num-rational", 173 + "num-traits", 174 + "pastey", 175 + "rayon", 176 + "thiserror 2.0.18", 177 + "v_frame", 178 + "y4m", 179 + ] 180 + 181 + [[package]] 182 + name = "av1-grain" 183 + version = "0.2.5" 184 + source = "registry+https://github.com/rust-lang/crates.io-index" 185 + checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" 186 + dependencies = [ 187 + "anyhow", 188 + "arrayvec", 189 + "log", 190 + "nom", 191 + "num-rational", 192 + "v_frame", 193 + ] 194 + 195 + [[package]] 196 + name = "avif-serialize" 197 + version = "0.8.8" 198 + source = "registry+https://github.com/rust-lang/crates.io-index" 199 + checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" 200 + dependencies = [ 201 + "arrayvec", 202 + ] 203 + 204 + [[package]] 205 + name = "base64" 206 + version = "0.22.1" 207 + source = "registry+https://github.com/rust-lang/crates.io-index" 208 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 209 + 210 + [[package]] 211 + name = "bit_field" 212 + version = "0.10.3" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" 215 + 216 + [[package]] 217 + name = "bitflags" 218 + version = "2.11.0" 219 + source = "registry+https://github.com/rust-lang/crates.io-index" 220 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 221 + 222 + [[package]] 223 + name = "bitstream-io" 224 + version = "4.9.0" 225 + source = "registry+https://github.com/rust-lang/crates.io-index" 226 + checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" 227 + dependencies = [ 228 + "core2", 229 + ] 230 + 231 + [[package]] 232 + name = "block-buffer" 233 + version = "0.10.4" 234 + source = "registry+https://github.com/rust-lang/crates.io-index" 235 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 236 + dependencies = [ 237 + "generic-array", 238 + ] 239 + 240 + [[package]] 241 + name = "browser-stream" 242 + version = "0.1.0" 243 + dependencies = [ 244 + "anyhow", 245 + "assert_matches", 246 + "base64", 247 + "chromiumoxide", 248 + "chromiumoxide_cdp", 249 + "clap", 250 + "futures", 251 + "image", 252 + "thiserror 2.0.18", 253 + "tokio", 254 + "tracing", 255 + "tracing-subscriber", 256 + "url", 257 + ] 258 + 259 + [[package]] 260 + name = "built" 261 + version = "0.8.0" 262 + source = "registry+https://github.com/rust-lang/crates.io-index" 263 + checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" 264 + 265 + [[package]] 266 + name = "bumpalo" 267 + version = "3.20.1" 268 + source = "registry+https://github.com/rust-lang/crates.io-index" 269 + checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" 270 + 271 + [[package]] 272 + name = "bytemuck" 273 + version = "1.25.0" 274 + source = "registry+https://github.com/rust-lang/crates.io-index" 275 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" 276 + 277 + [[package]] 278 + name = "byteorder-lite" 279 + version = "0.1.0" 280 + source = "registry+https://github.com/rust-lang/crates.io-index" 281 + checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 282 + 283 + [[package]] 284 + name = "bytes" 285 + version = "1.11.1" 286 + source = "registry+https://github.com/rust-lang/crates.io-index" 287 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 288 + dependencies = [ 289 + "serde", 290 + ] 291 + 292 + [[package]] 293 + name = "cc" 294 + version = "1.2.56" 295 + source = "registry+https://github.com/rust-lang/crates.io-index" 296 + checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" 297 + dependencies = [ 298 + "find-msvc-tools", 299 + "jobserver", 300 + "libc", 301 + "shlex", 302 + ] 303 + 304 + [[package]] 305 + name = "cfg-if" 306 + version = "1.0.4" 307 + source = "registry+https://github.com/rust-lang/crates.io-index" 308 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 309 + 310 + [[package]] 311 + name = "chromiumoxide" 312 + version = "0.8.0" 313 + source = "registry+https://github.com/rust-lang/crates.io-index" 314 + checksum = "6c18200611490f523adb497ddd4744d6d536e243f6add13e7eeeb1c05904fbb1" 315 + dependencies = [ 316 + "async-tungstenite", 317 + "base64", 318 + "bytes", 319 + "cfg-if", 320 + "chromiumoxide_cdp", 321 + "chromiumoxide_types", 322 + "dunce", 323 + "fnv", 324 + "futures", 325 + "futures-timer", 326 + "pin-project-lite", 327 + "reqwest", 328 + "serde", 329 + "serde_json", 330 + "thiserror 1.0.69", 331 + "tokio", 332 + "tracing", 333 + "url", 334 + "which", 335 + "windows-registry", 336 + ] 337 + 338 + [[package]] 339 + name = "chromiumoxide_cdp" 340 + version = "0.8.0" 341 + source = "registry+https://github.com/rust-lang/crates.io-index" 342 + checksum = "b8f78027ced540595dcbaf9e2f3413cbe3708b839ff239d2858acaea73915dcb" 343 + dependencies = [ 344 + "chromiumoxide_pdl", 345 + "chromiumoxide_types", 346 + "serde", 347 + "serde_json", 348 + ] 349 + 350 + [[package]] 351 + name = "chromiumoxide_pdl" 352 + version = "0.8.0" 353 + source = "registry+https://github.com/rust-lang/crates.io-index" 354 + checksum = "0d2c7b7c6b41a0de36d00a284e619017e0f4aec5c9bc8d90614b9e1687984f20" 355 + dependencies = [ 356 + "chromiumoxide_types", 357 + "either", 358 + "heck 0.4.1", 359 + "once_cell", 360 + "proc-macro2", 361 + "quote", 362 + "regex", 363 + "serde", 364 + "serde_json", 365 + ] 366 + 367 + [[package]] 368 + name = "chromiumoxide_types" 369 + version = "0.8.0" 370 + source = "registry+https://github.com/rust-lang/crates.io-index" 371 + checksum = "309ba8f378bbc093c93f06beb7bd4c5ceffdf14107ad99cacbbf063709926795" 372 + dependencies = [ 373 + "serde", 374 + "serde_json", 375 + ] 376 + 377 + [[package]] 378 + name = "clap" 379 + version = "4.5.59" 380 + source = "registry+https://github.com/rust-lang/crates.io-index" 381 + checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" 382 + dependencies = [ 383 + "clap_builder", 384 + "clap_derive", 385 + ] 386 + 387 + [[package]] 388 + name = "clap_builder" 389 + version = "4.5.59" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" 392 + dependencies = [ 393 + "anstream", 394 + "anstyle", 395 + "clap_lex", 396 + "strsim", 397 + ] 398 + 399 + [[package]] 400 + name = "clap_derive" 401 + version = "4.5.55" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" 404 + dependencies = [ 405 + "heck 0.5.0", 406 + "proc-macro2", 407 + "quote", 408 + "syn", 409 + ] 410 + 411 + [[package]] 412 + name = "clap_lex" 413 + version = "1.0.0" 414 + source = "registry+https://github.com/rust-lang/crates.io-index" 415 + checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 416 + 417 + [[package]] 418 + name = "color_quant" 419 + version = "1.1.0" 420 + source = "registry+https://github.com/rust-lang/crates.io-index" 421 + checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 422 + 423 + [[package]] 424 + name = "colorchoice" 425 + version = "1.0.4" 426 + source = "registry+https://github.com/rust-lang/crates.io-index" 427 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 428 + 429 + [[package]] 430 + name = "core2" 431 + version = "0.4.0" 432 + source = "registry+https://github.com/rust-lang/crates.io-index" 433 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 434 + dependencies = [ 435 + "memchr", 436 + ] 437 + 438 + [[package]] 439 + name = "cpufeatures" 440 + version = "0.2.17" 441 + source = "registry+https://github.com/rust-lang/crates.io-index" 442 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 443 + dependencies = [ 444 + "libc", 445 + ] 446 + 447 + [[package]] 448 + name = "crc32fast" 449 + version = "1.5.0" 450 + source = "registry+https://github.com/rust-lang/crates.io-index" 451 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 452 + dependencies = [ 453 + "cfg-if", 454 + ] 455 + 456 + [[package]] 457 + name = "crossbeam-deque" 458 + version = "0.8.6" 459 + source = "registry+https://github.com/rust-lang/crates.io-index" 460 + checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 461 + dependencies = [ 462 + "crossbeam-epoch", 463 + "crossbeam-utils", 464 + ] 465 + 466 + [[package]] 467 + name = "crossbeam-epoch" 468 + version = "0.9.18" 469 + source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 471 + dependencies = [ 472 + "crossbeam-utils", 473 + ] 474 + 475 + [[package]] 476 + name = "crossbeam-utils" 477 + version = "0.8.21" 478 + source = "registry+https://github.com/rust-lang/crates.io-index" 479 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 480 + 481 + [[package]] 482 + name = "crunchy" 483 + version = "0.2.4" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 486 + 487 + [[package]] 488 + name = "crypto-common" 489 + version = "0.1.7" 490 + source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 492 + dependencies = [ 493 + "generic-array", 494 + "typenum", 495 + ] 496 + 497 + [[package]] 498 + name = "data-encoding" 499 + version = "2.10.0" 500 + source = "registry+https://github.com/rust-lang/crates.io-index" 501 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 502 + 503 + [[package]] 504 + name = "digest" 505 + version = "0.10.7" 506 + source = "registry+https://github.com/rust-lang/crates.io-index" 507 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 508 + dependencies = [ 509 + "block-buffer", 510 + "crypto-common", 511 + ] 512 + 513 + [[package]] 514 + name = "displaydoc" 515 + version = "0.2.5" 516 + source = "registry+https://github.com/rust-lang/crates.io-index" 517 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 518 + dependencies = [ 519 + "proc-macro2", 520 + "quote", 521 + "syn", 522 + ] 523 + 524 + [[package]] 525 + name = "dunce" 526 + version = "1.0.5" 527 + source = "registry+https://github.com/rust-lang/crates.io-index" 528 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 529 + 530 + [[package]] 531 + name = "either" 532 + version = "1.15.0" 533 + source = "registry+https://github.com/rust-lang/crates.io-index" 534 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 535 + 536 + [[package]] 537 + name = "env_home" 538 + version = "0.1.0" 539 + source = "registry+https://github.com/rust-lang/crates.io-index" 540 + checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" 541 + 542 + [[package]] 543 + name = "equator" 544 + version = "0.4.2" 545 + source = "registry+https://github.com/rust-lang/crates.io-index" 546 + checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" 547 + dependencies = [ 548 + "equator-macro", 549 + ] 550 + 551 + [[package]] 552 + name = "equator-macro" 553 + version = "0.4.2" 554 + source = "registry+https://github.com/rust-lang/crates.io-index" 555 + checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" 556 + dependencies = [ 557 + "proc-macro2", 558 + "quote", 559 + "syn", 560 + ] 561 + 562 + [[package]] 563 + name = "errno" 564 + version = "0.3.14" 565 + source = "registry+https://github.com/rust-lang/crates.io-index" 566 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 567 + dependencies = [ 568 + "libc", 569 + "windows-sys 0.61.2", 570 + ] 571 + 572 + [[package]] 573 + name = "exr" 574 + version = "1.74.0" 575 + source = "registry+https://github.com/rust-lang/crates.io-index" 576 + checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" 577 + dependencies = [ 578 + "bit_field", 579 + "half", 580 + "lebe", 581 + "miniz_oxide", 582 + "rayon-core", 583 + "smallvec", 584 + "zune-inflate", 585 + ] 586 + 587 + [[package]] 588 + name = "fax" 589 + version = "0.2.6" 590 + source = "registry+https://github.com/rust-lang/crates.io-index" 591 + checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" 592 + dependencies = [ 593 + "fax_derive", 594 + ] 595 + 596 + [[package]] 597 + name = "fax_derive" 598 + version = "0.2.0" 599 + source = "registry+https://github.com/rust-lang/crates.io-index" 600 + checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" 601 + dependencies = [ 602 + "proc-macro2", 603 + "quote", 604 + "syn", 605 + ] 606 + 607 + [[package]] 608 + name = "fdeflate" 609 + version = "0.3.7" 610 + source = "registry+https://github.com/rust-lang/crates.io-index" 611 + checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" 612 + dependencies = [ 613 + "simd-adler32", 614 + ] 615 + 616 + [[package]] 617 + name = "find-msvc-tools" 618 + version = "0.1.9" 619 + source = "registry+https://github.com/rust-lang/crates.io-index" 620 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 621 + 622 + [[package]] 623 + name = "flate2" 624 + version = "1.1.9" 625 + source = "registry+https://github.com/rust-lang/crates.io-index" 626 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 627 + dependencies = [ 628 + "crc32fast", 629 + "miniz_oxide", 630 + ] 631 + 632 + [[package]] 633 + name = "fnv" 634 + version = "1.0.7" 635 + source = "registry+https://github.com/rust-lang/crates.io-index" 636 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 637 + 638 + [[package]] 639 + name = "form_urlencoded" 640 + version = "1.2.2" 641 + source = "registry+https://github.com/rust-lang/crates.io-index" 642 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 643 + dependencies = [ 644 + "percent-encoding", 645 + ] 646 + 647 + [[package]] 648 + name = "futures" 649 + version = "0.3.32" 650 + source = "registry+https://github.com/rust-lang/crates.io-index" 651 + checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 652 + dependencies = [ 653 + "futures-channel", 654 + "futures-core", 655 + "futures-executor", 656 + "futures-io", 657 + "futures-sink", 658 + "futures-task", 659 + "futures-util", 660 + ] 661 + 662 + [[package]] 663 + name = "futures-channel" 664 + version = "0.3.32" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 667 + dependencies = [ 668 + "futures-core", 669 + "futures-sink", 670 + ] 671 + 672 + [[package]] 673 + name = "futures-core" 674 + version = "0.3.32" 675 + source = "registry+https://github.com/rust-lang/crates.io-index" 676 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 677 + 678 + [[package]] 679 + name = "futures-executor" 680 + version = "0.3.32" 681 + source = "registry+https://github.com/rust-lang/crates.io-index" 682 + checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" 683 + dependencies = [ 684 + "futures-core", 685 + "futures-task", 686 + "futures-util", 687 + ] 688 + 689 + [[package]] 690 + name = "futures-io" 691 + version = "0.3.32" 692 + source = "registry+https://github.com/rust-lang/crates.io-index" 693 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 694 + 695 + [[package]] 696 + name = "futures-macro" 697 + version = "0.3.32" 698 + source = "registry+https://github.com/rust-lang/crates.io-index" 699 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 700 + dependencies = [ 701 + "proc-macro2", 702 + "quote", 703 + "syn", 704 + ] 705 + 706 + [[package]] 707 + name = "futures-sink" 708 + version = "0.3.32" 709 + source = "registry+https://github.com/rust-lang/crates.io-index" 710 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 711 + 712 + [[package]] 713 + name = "futures-task" 714 + version = "0.3.32" 715 + source = "registry+https://github.com/rust-lang/crates.io-index" 716 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 717 + 718 + [[package]] 719 + name = "futures-timer" 720 + version = "3.0.3" 721 + source = "registry+https://github.com/rust-lang/crates.io-index" 722 + checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" 723 + 724 + [[package]] 725 + name = "futures-util" 726 + version = "0.3.32" 727 + source = "registry+https://github.com/rust-lang/crates.io-index" 728 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 729 + dependencies = [ 730 + "futures-channel", 731 + "futures-core", 732 + "futures-io", 733 + "futures-macro", 734 + "futures-sink", 735 + "futures-task", 736 + "memchr", 737 + "pin-project-lite", 738 + "slab", 739 + ] 740 + 741 + [[package]] 742 + name = "generic-array" 743 + version = "0.14.7" 744 + source = "registry+https://github.com/rust-lang/crates.io-index" 745 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 746 + dependencies = [ 747 + "typenum", 748 + "version_check", 749 + ] 750 + 751 + [[package]] 752 + name = "getrandom" 753 + version = "0.3.4" 754 + source = "registry+https://github.com/rust-lang/crates.io-index" 755 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 756 + dependencies = [ 757 + "cfg-if", 758 + "libc", 759 + "r-efi", 760 + "wasip2", 761 + ] 762 + 763 + [[package]] 764 + name = "gif" 765 + version = "0.14.1" 766 + source = "registry+https://github.com/rust-lang/crates.io-index" 767 + checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" 768 + dependencies = [ 769 + "color_quant", 770 + "weezl", 771 + ] 772 + 773 + [[package]] 774 + name = "half" 775 + version = "2.7.1" 776 + source = "registry+https://github.com/rust-lang/crates.io-index" 777 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 778 + dependencies = [ 779 + "cfg-if", 780 + "crunchy", 781 + "zerocopy", 782 + ] 783 + 784 + [[package]] 785 + name = "heck" 786 + version = "0.4.1" 787 + source = "registry+https://github.com/rust-lang/crates.io-index" 788 + checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 789 + 790 + [[package]] 791 + name = "heck" 792 + version = "0.5.0" 793 + source = "registry+https://github.com/rust-lang/crates.io-index" 794 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 795 + 796 + [[package]] 797 + name = "http" 798 + version = "1.4.0" 799 + source = "registry+https://github.com/rust-lang/crates.io-index" 800 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 801 + dependencies = [ 802 + "bytes", 803 + "itoa", 804 + ] 805 + 806 + [[package]] 807 + name = "http-body" 808 + version = "1.0.1" 809 + source = "registry+https://github.com/rust-lang/crates.io-index" 810 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 811 + dependencies = [ 812 + "bytes", 813 + "http", 814 + ] 815 + 816 + [[package]] 817 + name = "http-body-util" 818 + version = "0.1.3" 819 + source = "registry+https://github.com/rust-lang/crates.io-index" 820 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 821 + dependencies = [ 822 + "bytes", 823 + "futures-core", 824 + "http", 825 + "http-body", 826 + "pin-project-lite", 827 + ] 828 + 829 + [[package]] 830 + name = "httparse" 831 + version = "1.10.1" 832 + source = "registry+https://github.com/rust-lang/crates.io-index" 833 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 834 + 835 + [[package]] 836 + name = "hyper" 837 + version = "1.8.1" 838 + source = "registry+https://github.com/rust-lang/crates.io-index" 839 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 840 + dependencies = [ 841 + "atomic-waker", 842 + "bytes", 843 + "futures-channel", 844 + "futures-core", 845 + "http", 846 + "http-body", 847 + "httparse", 848 + "itoa", 849 + "pin-project-lite", 850 + "pin-utils", 851 + "smallvec", 852 + "tokio", 853 + "want", 854 + ] 855 + 856 + [[package]] 857 + name = "hyper-util" 858 + version = "0.1.20" 859 + source = "registry+https://github.com/rust-lang/crates.io-index" 860 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 861 + dependencies = [ 862 + "base64", 863 + "bytes", 864 + "futures-channel", 865 + "futures-util", 866 + "http", 867 + "http-body", 868 + "hyper", 869 + "ipnet", 870 + "libc", 871 + "percent-encoding", 872 + "pin-project-lite", 873 + "socket2", 874 + "tokio", 875 + "tower-service", 876 + "tracing", 877 + ] 878 + 879 + [[package]] 880 + name = "icu_collections" 881 + version = "2.1.1" 882 + source = "registry+https://github.com/rust-lang/crates.io-index" 883 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 884 + dependencies = [ 885 + "displaydoc", 886 + "potential_utf", 887 + "yoke", 888 + "zerofrom", 889 + "zerovec", 890 + ] 891 + 892 + [[package]] 893 + name = "icu_locale_core" 894 + version = "2.1.1" 895 + source = "registry+https://github.com/rust-lang/crates.io-index" 896 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 897 + dependencies = [ 898 + "displaydoc", 899 + "litemap", 900 + "tinystr", 901 + "writeable", 902 + "zerovec", 903 + ] 904 + 905 + [[package]] 906 + name = "icu_normalizer" 907 + version = "2.1.1" 908 + source = "registry+https://github.com/rust-lang/crates.io-index" 909 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 910 + dependencies = [ 911 + "icu_collections", 912 + "icu_normalizer_data", 913 + "icu_properties", 914 + "icu_provider", 915 + "smallvec", 916 + "zerovec", 917 + ] 918 + 919 + [[package]] 920 + name = "icu_normalizer_data" 921 + version = "2.1.1" 922 + source = "registry+https://github.com/rust-lang/crates.io-index" 923 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 924 + 925 + [[package]] 926 + name = "icu_properties" 927 + version = "2.1.2" 928 + source = "registry+https://github.com/rust-lang/crates.io-index" 929 + checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" 930 + dependencies = [ 931 + "icu_collections", 932 + "icu_locale_core", 933 + "icu_properties_data", 934 + "icu_provider", 935 + "zerotrie", 936 + "zerovec", 937 + ] 938 + 939 + [[package]] 940 + name = "icu_properties_data" 941 + version = "2.1.2" 942 + source = "registry+https://github.com/rust-lang/crates.io-index" 943 + checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" 944 + 945 + [[package]] 946 + name = "icu_provider" 947 + version = "2.1.1" 948 + source = "registry+https://github.com/rust-lang/crates.io-index" 949 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 950 + dependencies = [ 951 + "displaydoc", 952 + "icu_locale_core", 953 + "writeable", 954 + "yoke", 955 + "zerofrom", 956 + "zerotrie", 957 + "zerovec", 958 + ] 959 + 960 + [[package]] 961 + name = "idna" 962 + version = "1.1.0" 963 + source = "registry+https://github.com/rust-lang/crates.io-index" 964 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 965 + dependencies = [ 966 + "idna_adapter", 967 + "smallvec", 968 + "utf8_iter", 969 + ] 970 + 971 + [[package]] 972 + name = "idna_adapter" 973 + version = "1.2.1" 974 + source = "registry+https://github.com/rust-lang/crates.io-index" 975 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 976 + dependencies = [ 977 + "icu_normalizer", 978 + "icu_properties", 979 + ] 980 + 981 + [[package]] 982 + name = "image" 983 + version = "0.25.9" 984 + source = "registry+https://github.com/rust-lang/crates.io-index" 985 + checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" 986 + dependencies = [ 987 + "bytemuck", 988 + "byteorder-lite", 989 + "color_quant", 990 + "exr", 991 + "gif", 992 + "image-webp", 993 + "moxcms", 994 + "num-traits", 995 + "png", 996 + "qoi", 997 + "ravif", 998 + "rayon", 999 + "rgb", 1000 + "tiff", 1001 + "zune-core 0.5.1", 1002 + "zune-jpeg 0.5.12", 1003 + ] 1004 + 1005 + [[package]] 1006 + name = "image-webp" 1007 + version = "0.2.4" 1008 + source = "registry+https://github.com/rust-lang/crates.io-index" 1009 + checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" 1010 + dependencies = [ 1011 + "byteorder-lite", 1012 + "quick-error", 1013 + ] 1014 + 1015 + [[package]] 1016 + name = "imgref" 1017 + version = "1.12.0" 1018 + source = "registry+https://github.com/rust-lang/crates.io-index" 1019 + checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" 1020 + 1021 + [[package]] 1022 + name = "interpolate_name" 1023 + version = "0.2.4" 1024 + source = "registry+https://github.com/rust-lang/crates.io-index" 1025 + checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" 1026 + dependencies = [ 1027 + "proc-macro2", 1028 + "quote", 1029 + "syn", 1030 + ] 1031 + 1032 + [[package]] 1033 + name = "ipnet" 1034 + version = "2.11.0" 1035 + source = "registry+https://github.com/rust-lang/crates.io-index" 1036 + checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 1037 + 1038 + [[package]] 1039 + name = "iri-string" 1040 + version = "0.7.10" 1041 + source = "registry+https://github.com/rust-lang/crates.io-index" 1042 + checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" 1043 + dependencies = [ 1044 + "memchr", 1045 + "serde", 1046 + ] 1047 + 1048 + [[package]] 1049 + name = "is_terminal_polyfill" 1050 + version = "1.70.2" 1051 + source = "registry+https://github.com/rust-lang/crates.io-index" 1052 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1053 + 1054 + [[package]] 1055 + name = "itertools" 1056 + version = "0.14.0" 1057 + source = "registry+https://github.com/rust-lang/crates.io-index" 1058 + checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" 1059 + dependencies = [ 1060 + "either", 1061 + ] 1062 + 1063 + [[package]] 1064 + name = "itoa" 1065 + version = "1.0.17" 1066 + source = "registry+https://github.com/rust-lang/crates.io-index" 1067 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 1068 + 1069 + [[package]] 1070 + name = "jobserver" 1071 + version = "0.1.34" 1072 + source = "registry+https://github.com/rust-lang/crates.io-index" 1073 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1074 + dependencies = [ 1075 + "getrandom", 1076 + "libc", 1077 + ] 1078 + 1079 + [[package]] 1080 + name = "js-sys" 1081 + version = "0.3.85" 1082 + source = "registry+https://github.com/rust-lang/crates.io-index" 1083 + checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" 1084 + dependencies = [ 1085 + "once_cell", 1086 + "wasm-bindgen", 1087 + ] 1088 + 1089 + [[package]] 1090 + name = "lazy_static" 1091 + version = "1.5.0" 1092 + source = "registry+https://github.com/rust-lang/crates.io-index" 1093 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1094 + 1095 + [[package]] 1096 + name = "lebe" 1097 + version = "0.5.3" 1098 + source = "registry+https://github.com/rust-lang/crates.io-index" 1099 + checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" 1100 + 1101 + [[package]] 1102 + name = "libc" 1103 + version = "0.2.182" 1104 + source = "registry+https://github.com/rust-lang/crates.io-index" 1105 + checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 1106 + 1107 + [[package]] 1108 + name = "libfuzzer-sys" 1109 + version = "0.4.12" 1110 + source = "registry+https://github.com/rust-lang/crates.io-index" 1111 + checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" 1112 + dependencies = [ 1113 + "arbitrary", 1114 + "cc", 1115 + ] 1116 + 1117 + [[package]] 1118 + name = "linux-raw-sys" 1119 + version = "0.11.0" 1120 + source = "registry+https://github.com/rust-lang/crates.io-index" 1121 + checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 1122 + 1123 + [[package]] 1124 + name = "litemap" 1125 + version = "0.8.1" 1126 + source = "registry+https://github.com/rust-lang/crates.io-index" 1127 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 1128 + 1129 + [[package]] 1130 + name = "lock_api" 1131 + version = "0.4.14" 1132 + source = "registry+https://github.com/rust-lang/crates.io-index" 1133 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1134 + dependencies = [ 1135 + "scopeguard", 1136 + ] 1137 + 1138 + [[package]] 1139 + name = "log" 1140 + version = "0.4.29" 1141 + source = "registry+https://github.com/rust-lang/crates.io-index" 1142 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1143 + 1144 + [[package]] 1145 + name = "loop9" 1146 + version = "0.1.5" 1147 + source = "registry+https://github.com/rust-lang/crates.io-index" 1148 + checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" 1149 + dependencies = [ 1150 + "imgref", 1151 + ] 1152 + 1153 + [[package]] 1154 + name = "matchers" 1155 + version = "0.2.0" 1156 + source = "registry+https://github.com/rust-lang/crates.io-index" 1157 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1158 + dependencies = [ 1159 + "regex-automata", 1160 + ] 1161 + 1162 + [[package]] 1163 + name = "maybe-rayon" 1164 + version = "0.1.1" 1165 + source = "registry+https://github.com/rust-lang/crates.io-index" 1166 + checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" 1167 + dependencies = [ 1168 + "cfg-if", 1169 + "rayon", 1170 + ] 1171 + 1172 + [[package]] 1173 + name = "memchr" 1174 + version = "2.8.0" 1175 + source = "registry+https://github.com/rust-lang/crates.io-index" 1176 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 1177 + 1178 + [[package]] 1179 + name = "miniz_oxide" 1180 + version = "0.8.9" 1181 + source = "registry+https://github.com/rust-lang/crates.io-index" 1182 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 1183 + dependencies = [ 1184 + "adler2", 1185 + "simd-adler32", 1186 + ] 1187 + 1188 + [[package]] 1189 + name = "mio" 1190 + version = "1.1.1" 1191 + source = "registry+https://github.com/rust-lang/crates.io-index" 1192 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 1193 + dependencies = [ 1194 + "libc", 1195 + "wasi", 1196 + "windows-sys 0.61.2", 1197 + ] 1198 + 1199 + [[package]] 1200 + name = "moxcms" 1201 + version = "0.7.11" 1202 + source = "registry+https://github.com/rust-lang/crates.io-index" 1203 + checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" 1204 + dependencies = [ 1205 + "num-traits", 1206 + "pxfm", 1207 + ] 1208 + 1209 + [[package]] 1210 + name = "new_debug_unreachable" 1211 + version = "1.0.6" 1212 + source = "registry+https://github.com/rust-lang/crates.io-index" 1213 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 1214 + 1215 + [[package]] 1216 + name = "nom" 1217 + version = "8.0.0" 1218 + source = "registry+https://github.com/rust-lang/crates.io-index" 1219 + checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" 1220 + dependencies = [ 1221 + "memchr", 1222 + ] 1223 + 1224 + [[package]] 1225 + name = "noop_proc_macro" 1226 + version = "0.3.0" 1227 + source = "registry+https://github.com/rust-lang/crates.io-index" 1228 + checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 1229 + 1230 + [[package]] 1231 + name = "nu-ansi-term" 1232 + version = "0.50.3" 1233 + source = "registry+https://github.com/rust-lang/crates.io-index" 1234 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1235 + dependencies = [ 1236 + "windows-sys 0.61.2", 1237 + ] 1238 + 1239 + [[package]] 1240 + name = "num-bigint" 1241 + version = "0.4.6" 1242 + source = "registry+https://github.com/rust-lang/crates.io-index" 1243 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1244 + dependencies = [ 1245 + "num-integer", 1246 + "num-traits", 1247 + ] 1248 + 1249 + [[package]] 1250 + name = "num-derive" 1251 + version = "0.4.2" 1252 + source = "registry+https://github.com/rust-lang/crates.io-index" 1253 + checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" 1254 + dependencies = [ 1255 + "proc-macro2", 1256 + "quote", 1257 + "syn", 1258 + ] 1259 + 1260 + [[package]] 1261 + name = "num-integer" 1262 + version = "0.1.46" 1263 + source = "registry+https://github.com/rust-lang/crates.io-index" 1264 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1265 + dependencies = [ 1266 + "num-traits", 1267 + ] 1268 + 1269 + [[package]] 1270 + name = "num-rational" 1271 + version = "0.4.2" 1272 + source = "registry+https://github.com/rust-lang/crates.io-index" 1273 + checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 1274 + dependencies = [ 1275 + "num-bigint", 1276 + "num-integer", 1277 + "num-traits", 1278 + ] 1279 + 1280 + [[package]] 1281 + name = "num-traits" 1282 + version = "0.2.19" 1283 + source = "registry+https://github.com/rust-lang/crates.io-index" 1284 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1285 + dependencies = [ 1286 + "autocfg", 1287 + ] 1288 + 1289 + [[package]] 1290 + name = "once_cell" 1291 + version = "1.21.3" 1292 + source = "registry+https://github.com/rust-lang/crates.io-index" 1293 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1294 + 1295 + [[package]] 1296 + name = "once_cell_polyfill" 1297 + version = "1.70.2" 1298 + source = "registry+https://github.com/rust-lang/crates.io-index" 1299 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1300 + 1301 + [[package]] 1302 + name = "parking_lot" 1303 + version = "0.12.5" 1304 + source = "registry+https://github.com/rust-lang/crates.io-index" 1305 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1306 + dependencies = [ 1307 + "lock_api", 1308 + "parking_lot_core", 1309 + ] 1310 + 1311 + [[package]] 1312 + name = "parking_lot_core" 1313 + version = "0.9.12" 1314 + source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1316 + dependencies = [ 1317 + "cfg-if", 1318 + "libc", 1319 + "redox_syscall", 1320 + "smallvec", 1321 + "windows-link 0.2.1", 1322 + ] 1323 + 1324 + [[package]] 1325 + name = "paste" 1326 + version = "1.0.15" 1327 + source = "registry+https://github.com/rust-lang/crates.io-index" 1328 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1329 + 1330 + [[package]] 1331 + name = "pastey" 1332 + version = "0.1.1" 1333 + source = "registry+https://github.com/rust-lang/crates.io-index" 1334 + checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" 1335 + 1336 + [[package]] 1337 + name = "percent-encoding" 1338 + version = "2.3.2" 1339 + source = "registry+https://github.com/rust-lang/crates.io-index" 1340 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1341 + 1342 + [[package]] 1343 + name = "pin-project-lite" 1344 + version = "0.2.16" 1345 + source = "registry+https://github.com/rust-lang/crates.io-index" 1346 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1347 + 1348 + [[package]] 1349 + name = "pin-utils" 1350 + version = "0.1.0" 1351 + source = "registry+https://github.com/rust-lang/crates.io-index" 1352 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1353 + 1354 + [[package]] 1355 + name = "png" 1356 + version = "0.18.1" 1357 + source = "registry+https://github.com/rust-lang/crates.io-index" 1358 + checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" 1359 + dependencies = [ 1360 + "bitflags", 1361 + "crc32fast", 1362 + "fdeflate", 1363 + "flate2", 1364 + "miniz_oxide", 1365 + ] 1366 + 1367 + [[package]] 1368 + name = "potential_utf" 1369 + version = "0.1.4" 1370 + source = "registry+https://github.com/rust-lang/crates.io-index" 1371 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 1372 + dependencies = [ 1373 + "zerovec", 1374 + ] 1375 + 1376 + [[package]] 1377 + name = "ppv-lite86" 1378 + version = "0.2.21" 1379 + source = "registry+https://github.com/rust-lang/crates.io-index" 1380 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1381 + dependencies = [ 1382 + "zerocopy", 1383 + ] 1384 + 1385 + [[package]] 1386 + name = "proc-macro2" 1387 + version = "1.0.106" 1388 + source = "registry+https://github.com/rust-lang/crates.io-index" 1389 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1390 + dependencies = [ 1391 + "unicode-ident", 1392 + ] 1393 + 1394 + [[package]] 1395 + name = "profiling" 1396 + version = "1.0.17" 1397 + source = "registry+https://github.com/rust-lang/crates.io-index" 1398 + checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" 1399 + dependencies = [ 1400 + "profiling-procmacros", 1401 + ] 1402 + 1403 + [[package]] 1404 + name = "profiling-procmacros" 1405 + version = "1.0.17" 1406 + source = "registry+https://github.com/rust-lang/crates.io-index" 1407 + checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" 1408 + dependencies = [ 1409 + "quote", 1410 + "syn", 1411 + ] 1412 + 1413 + [[package]] 1414 + name = "pxfm" 1415 + version = "0.1.27" 1416 + source = "registry+https://github.com/rust-lang/crates.io-index" 1417 + checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" 1418 + dependencies = [ 1419 + "num-traits", 1420 + ] 1421 + 1422 + [[package]] 1423 + name = "qoi" 1424 + version = "0.4.1" 1425 + source = "registry+https://github.com/rust-lang/crates.io-index" 1426 + checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" 1427 + dependencies = [ 1428 + "bytemuck", 1429 + ] 1430 + 1431 + [[package]] 1432 + name = "quick-error" 1433 + version = "2.0.1" 1434 + source = "registry+https://github.com/rust-lang/crates.io-index" 1435 + checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 1436 + 1437 + [[package]] 1438 + name = "quote" 1439 + version = "1.0.44" 1440 + source = "registry+https://github.com/rust-lang/crates.io-index" 1441 + checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" 1442 + dependencies = [ 1443 + "proc-macro2", 1444 + ] 1445 + 1446 + [[package]] 1447 + name = "r-efi" 1448 + version = "5.3.0" 1449 + source = "registry+https://github.com/rust-lang/crates.io-index" 1450 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1451 + 1452 + [[package]] 1453 + name = "rand" 1454 + version = "0.9.2" 1455 + source = "registry+https://github.com/rust-lang/crates.io-index" 1456 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1457 + dependencies = [ 1458 + "rand_chacha", 1459 + "rand_core", 1460 + ] 1461 + 1462 + [[package]] 1463 + name = "rand_chacha" 1464 + version = "0.9.0" 1465 + source = "registry+https://github.com/rust-lang/crates.io-index" 1466 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1467 + dependencies = [ 1468 + "ppv-lite86", 1469 + "rand_core", 1470 + ] 1471 + 1472 + [[package]] 1473 + name = "rand_core" 1474 + version = "0.9.5" 1475 + source = "registry+https://github.com/rust-lang/crates.io-index" 1476 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 1477 + dependencies = [ 1478 + "getrandom", 1479 + ] 1480 + 1481 + [[package]] 1482 + name = "rav1e" 1483 + version = "0.8.1" 1484 + source = "registry+https://github.com/rust-lang/crates.io-index" 1485 + checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" 1486 + dependencies = [ 1487 + "aligned-vec", 1488 + "arbitrary", 1489 + "arg_enum_proc_macro", 1490 + "arrayvec", 1491 + "av-scenechange", 1492 + "av1-grain", 1493 + "bitstream-io", 1494 + "built", 1495 + "cfg-if", 1496 + "interpolate_name", 1497 + "itertools", 1498 + "libc", 1499 + "libfuzzer-sys", 1500 + "log", 1501 + "maybe-rayon", 1502 + "new_debug_unreachable", 1503 + "noop_proc_macro", 1504 + "num-derive", 1505 + "num-traits", 1506 + "paste", 1507 + "profiling", 1508 + "rand", 1509 + "rand_chacha", 1510 + "simd_helpers", 1511 + "thiserror 2.0.18", 1512 + "v_frame", 1513 + "wasm-bindgen", 1514 + ] 1515 + 1516 + [[package]] 1517 + name = "ravif" 1518 + version = "0.12.0" 1519 + source = "registry+https://github.com/rust-lang/crates.io-index" 1520 + checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" 1521 + dependencies = [ 1522 + "avif-serialize", 1523 + "imgref", 1524 + "loop9", 1525 + "quick-error", 1526 + "rav1e", 1527 + "rayon", 1528 + "rgb", 1529 + ] 1530 + 1531 + [[package]] 1532 + name = "rayon" 1533 + version = "1.11.0" 1534 + source = "registry+https://github.com/rust-lang/crates.io-index" 1535 + checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" 1536 + dependencies = [ 1537 + "either", 1538 + "rayon-core", 1539 + ] 1540 + 1541 + [[package]] 1542 + name = "rayon-core" 1543 + version = "1.13.0" 1544 + source = "registry+https://github.com/rust-lang/crates.io-index" 1545 + checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" 1546 + dependencies = [ 1547 + "crossbeam-deque", 1548 + "crossbeam-utils", 1549 + ] 1550 + 1551 + [[package]] 1552 + name = "redox_syscall" 1553 + version = "0.5.18" 1554 + source = "registry+https://github.com/rust-lang/crates.io-index" 1555 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1556 + dependencies = [ 1557 + "bitflags", 1558 + ] 1559 + 1560 + [[package]] 1561 + name = "regex" 1562 + version = "1.12.3" 1563 + source = "registry+https://github.com/rust-lang/crates.io-index" 1564 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 1565 + dependencies = [ 1566 + "aho-corasick", 1567 + "memchr", 1568 + "regex-automata", 1569 + "regex-syntax", 1570 + ] 1571 + 1572 + [[package]] 1573 + name = "regex-automata" 1574 + version = "0.4.14" 1575 + source = "registry+https://github.com/rust-lang/crates.io-index" 1576 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 1577 + dependencies = [ 1578 + "aho-corasick", 1579 + "memchr", 1580 + "regex-syntax", 1581 + ] 1582 + 1583 + [[package]] 1584 + name = "regex-syntax" 1585 + version = "0.8.9" 1586 + source = "registry+https://github.com/rust-lang/crates.io-index" 1587 + checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" 1588 + 1589 + [[package]] 1590 + name = "reqwest" 1591 + version = "0.12.28" 1592 + source = "registry+https://github.com/rust-lang/crates.io-index" 1593 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 1594 + dependencies = [ 1595 + "base64", 1596 + "bytes", 1597 + "futures-core", 1598 + "http", 1599 + "http-body", 1600 + "http-body-util", 1601 + "hyper", 1602 + "hyper-util", 1603 + "js-sys", 1604 + "log", 1605 + "percent-encoding", 1606 + "pin-project-lite", 1607 + "serde", 1608 + "serde_json", 1609 + "serde_urlencoded", 1610 + "sync_wrapper", 1611 + "tokio", 1612 + "tower", 1613 + "tower-http", 1614 + "tower-service", 1615 + "url", 1616 + "wasm-bindgen", 1617 + "wasm-bindgen-futures", 1618 + "web-sys", 1619 + ] 1620 + 1621 + [[package]] 1622 + name = "rgb" 1623 + version = "0.8.52" 1624 + source = "registry+https://github.com/rust-lang/crates.io-index" 1625 + checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" 1626 + 1627 + [[package]] 1628 + name = "rustix" 1629 + version = "1.1.3" 1630 + source = "registry+https://github.com/rust-lang/crates.io-index" 1631 + checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" 1632 + dependencies = [ 1633 + "bitflags", 1634 + "errno", 1635 + "libc", 1636 + "linux-raw-sys", 1637 + "windows-sys 0.61.2", 1638 + ] 1639 + 1640 + [[package]] 1641 + name = "rustversion" 1642 + version = "1.0.22" 1643 + source = "registry+https://github.com/rust-lang/crates.io-index" 1644 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1645 + 1646 + [[package]] 1647 + name = "ryu" 1648 + version = "1.0.23" 1649 + source = "registry+https://github.com/rust-lang/crates.io-index" 1650 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1651 + 1652 + [[package]] 1653 + name = "scopeguard" 1654 + version = "1.2.0" 1655 + source = "registry+https://github.com/rust-lang/crates.io-index" 1656 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1657 + 1658 + [[package]] 1659 + name = "serde" 1660 + version = "1.0.228" 1661 + source = "registry+https://github.com/rust-lang/crates.io-index" 1662 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1663 + dependencies = [ 1664 + "serde_core", 1665 + "serde_derive", 1666 + ] 1667 + 1668 + [[package]] 1669 + name = "serde_core" 1670 + version = "1.0.228" 1671 + source = "registry+https://github.com/rust-lang/crates.io-index" 1672 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1673 + dependencies = [ 1674 + "serde_derive", 1675 + ] 1676 + 1677 + [[package]] 1678 + name = "serde_derive" 1679 + version = "1.0.228" 1680 + source = "registry+https://github.com/rust-lang/crates.io-index" 1681 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1682 + dependencies = [ 1683 + "proc-macro2", 1684 + "quote", 1685 + "syn", 1686 + ] 1687 + 1688 + [[package]] 1689 + name = "serde_json" 1690 + version = "1.0.149" 1691 + source = "registry+https://github.com/rust-lang/crates.io-index" 1692 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 1693 + dependencies = [ 1694 + "itoa", 1695 + "memchr", 1696 + "serde", 1697 + "serde_core", 1698 + "zmij", 1699 + ] 1700 + 1701 + [[package]] 1702 + name = "serde_urlencoded" 1703 + version = "0.7.1" 1704 + source = "registry+https://github.com/rust-lang/crates.io-index" 1705 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1706 + dependencies = [ 1707 + "form_urlencoded", 1708 + "itoa", 1709 + "ryu", 1710 + "serde", 1711 + ] 1712 + 1713 + [[package]] 1714 + name = "sha1" 1715 + version = "0.10.6" 1716 + source = "registry+https://github.com/rust-lang/crates.io-index" 1717 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1718 + dependencies = [ 1719 + "cfg-if", 1720 + "cpufeatures", 1721 + "digest", 1722 + ] 1723 + 1724 + [[package]] 1725 + name = "sharded-slab" 1726 + version = "0.1.7" 1727 + source = "registry+https://github.com/rust-lang/crates.io-index" 1728 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1729 + dependencies = [ 1730 + "lazy_static", 1731 + ] 1732 + 1733 + [[package]] 1734 + name = "shlex" 1735 + version = "1.3.0" 1736 + source = "registry+https://github.com/rust-lang/crates.io-index" 1737 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1738 + 1739 + [[package]] 1740 + name = "signal-hook-registry" 1741 + version = "1.4.8" 1742 + source = "registry+https://github.com/rust-lang/crates.io-index" 1743 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1744 + dependencies = [ 1745 + "errno", 1746 + "libc", 1747 + ] 1748 + 1749 + [[package]] 1750 + name = "simd-adler32" 1751 + version = "0.3.8" 1752 + source = "registry+https://github.com/rust-lang/crates.io-index" 1753 + checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 1754 + 1755 + [[package]] 1756 + name = "simd_helpers" 1757 + version = "0.1.0" 1758 + source = "registry+https://github.com/rust-lang/crates.io-index" 1759 + checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" 1760 + dependencies = [ 1761 + "quote", 1762 + ] 1763 + 1764 + [[package]] 1765 + name = "slab" 1766 + version = "0.4.12" 1767 + source = "registry+https://github.com/rust-lang/crates.io-index" 1768 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1769 + 1770 + [[package]] 1771 + name = "smallvec" 1772 + version = "1.15.1" 1773 + source = "registry+https://github.com/rust-lang/crates.io-index" 1774 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1775 + 1776 + [[package]] 1777 + name = "socket2" 1778 + version = "0.6.2" 1779 + source = "registry+https://github.com/rust-lang/crates.io-index" 1780 + checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" 1781 + dependencies = [ 1782 + "libc", 1783 + "windows-sys 0.60.2", 1784 + ] 1785 + 1786 + [[package]] 1787 + name = "stable_deref_trait" 1788 + version = "1.2.1" 1789 + source = "registry+https://github.com/rust-lang/crates.io-index" 1790 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1791 + 1792 + [[package]] 1793 + name = "strsim" 1794 + version = "0.11.1" 1795 + source = "registry+https://github.com/rust-lang/crates.io-index" 1796 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1797 + 1798 + [[package]] 1799 + name = "syn" 1800 + version = "2.0.116" 1801 + source = "registry+https://github.com/rust-lang/crates.io-index" 1802 + checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" 1803 + dependencies = [ 1804 + "proc-macro2", 1805 + "quote", 1806 + "unicode-ident", 1807 + ] 1808 + 1809 + [[package]] 1810 + name = "sync_wrapper" 1811 + version = "1.0.2" 1812 + source = "registry+https://github.com/rust-lang/crates.io-index" 1813 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1814 + dependencies = [ 1815 + "futures-core", 1816 + ] 1817 + 1818 + [[package]] 1819 + name = "synstructure" 1820 + version = "0.13.2" 1821 + source = "registry+https://github.com/rust-lang/crates.io-index" 1822 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1823 + dependencies = [ 1824 + "proc-macro2", 1825 + "quote", 1826 + "syn", 1827 + ] 1828 + 1829 + [[package]] 1830 + name = "thiserror" 1831 + version = "1.0.69" 1832 + source = "registry+https://github.com/rust-lang/crates.io-index" 1833 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1834 + dependencies = [ 1835 + "thiserror-impl 1.0.69", 1836 + ] 1837 + 1838 + [[package]] 1839 + name = "thiserror" 1840 + version = "2.0.18" 1841 + source = "registry+https://github.com/rust-lang/crates.io-index" 1842 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1843 + dependencies = [ 1844 + "thiserror-impl 2.0.18", 1845 + ] 1846 + 1847 + [[package]] 1848 + name = "thiserror-impl" 1849 + version = "1.0.69" 1850 + source = "registry+https://github.com/rust-lang/crates.io-index" 1851 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1852 + dependencies = [ 1853 + "proc-macro2", 1854 + "quote", 1855 + "syn", 1856 + ] 1857 + 1858 + [[package]] 1859 + name = "thiserror-impl" 1860 + version = "2.0.18" 1861 + source = "registry+https://github.com/rust-lang/crates.io-index" 1862 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1863 + dependencies = [ 1864 + "proc-macro2", 1865 + "quote", 1866 + "syn", 1867 + ] 1868 + 1869 + [[package]] 1870 + name = "thread_local" 1871 + version = "1.1.9" 1872 + source = "registry+https://github.com/rust-lang/crates.io-index" 1873 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1874 + dependencies = [ 1875 + "cfg-if", 1876 + ] 1877 + 1878 + [[package]] 1879 + name = "tiff" 1880 + version = "0.10.3" 1881 + source = "registry+https://github.com/rust-lang/crates.io-index" 1882 + checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" 1883 + dependencies = [ 1884 + "fax", 1885 + "flate2", 1886 + "half", 1887 + "quick-error", 1888 + "weezl", 1889 + "zune-jpeg 0.4.21", 1890 + ] 1891 + 1892 + [[package]] 1893 + name = "tinystr" 1894 + version = "0.8.2" 1895 + source = "registry+https://github.com/rust-lang/crates.io-index" 1896 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 1897 + dependencies = [ 1898 + "displaydoc", 1899 + "zerovec", 1900 + ] 1901 + 1902 + [[package]] 1903 + name = "tokio" 1904 + version = "1.49.0" 1905 + source = "registry+https://github.com/rust-lang/crates.io-index" 1906 + checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" 1907 + dependencies = [ 1908 + "bytes", 1909 + "libc", 1910 + "mio", 1911 + "parking_lot", 1912 + "pin-project-lite", 1913 + "signal-hook-registry", 1914 + "socket2", 1915 + "tokio-macros", 1916 + "windows-sys 0.61.2", 1917 + ] 1918 + 1919 + [[package]] 1920 + name = "tokio-macros" 1921 + version = "2.6.0" 1922 + source = "registry+https://github.com/rust-lang/crates.io-index" 1923 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 1924 + dependencies = [ 1925 + "proc-macro2", 1926 + "quote", 1927 + "syn", 1928 + ] 1929 + 1930 + [[package]] 1931 + name = "tower" 1932 + version = "0.5.3" 1933 + source = "registry+https://github.com/rust-lang/crates.io-index" 1934 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 1935 + dependencies = [ 1936 + "futures-core", 1937 + "futures-util", 1938 + "pin-project-lite", 1939 + "sync_wrapper", 1940 + "tokio", 1941 + "tower-layer", 1942 + "tower-service", 1943 + ] 1944 + 1945 + [[package]] 1946 + name = "tower-http" 1947 + version = "0.6.8" 1948 + source = "registry+https://github.com/rust-lang/crates.io-index" 1949 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 1950 + dependencies = [ 1951 + "bitflags", 1952 + "bytes", 1953 + "futures-util", 1954 + "http", 1955 + "http-body", 1956 + "iri-string", 1957 + "pin-project-lite", 1958 + "tower", 1959 + "tower-layer", 1960 + "tower-service", 1961 + ] 1962 + 1963 + [[package]] 1964 + name = "tower-layer" 1965 + version = "0.3.3" 1966 + source = "registry+https://github.com/rust-lang/crates.io-index" 1967 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1968 + 1969 + [[package]] 1970 + name = "tower-service" 1971 + version = "0.3.3" 1972 + source = "registry+https://github.com/rust-lang/crates.io-index" 1973 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1974 + 1975 + [[package]] 1976 + name = "tracing" 1977 + version = "0.1.44" 1978 + source = "registry+https://github.com/rust-lang/crates.io-index" 1979 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1980 + dependencies = [ 1981 + "pin-project-lite", 1982 + "tracing-attributes", 1983 + "tracing-core", 1984 + ] 1985 + 1986 + [[package]] 1987 + name = "tracing-attributes" 1988 + version = "0.1.31" 1989 + source = "registry+https://github.com/rust-lang/crates.io-index" 1990 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 1991 + dependencies = [ 1992 + "proc-macro2", 1993 + "quote", 1994 + "syn", 1995 + ] 1996 + 1997 + [[package]] 1998 + name = "tracing-core" 1999 + version = "0.1.36" 2000 + source = "registry+https://github.com/rust-lang/crates.io-index" 2001 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 2002 + dependencies = [ 2003 + "once_cell", 2004 + "valuable", 2005 + ] 2006 + 2007 + [[package]] 2008 + name = "tracing-log" 2009 + version = "0.2.0" 2010 + source = "registry+https://github.com/rust-lang/crates.io-index" 2011 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2012 + dependencies = [ 2013 + "log", 2014 + "once_cell", 2015 + "tracing-core", 2016 + ] 2017 + 2018 + [[package]] 2019 + name = "tracing-subscriber" 2020 + version = "0.3.22" 2021 + source = "registry+https://github.com/rust-lang/crates.io-index" 2022 + checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" 2023 + dependencies = [ 2024 + "matchers", 2025 + "nu-ansi-term", 2026 + "once_cell", 2027 + "regex-automata", 2028 + "sharded-slab", 2029 + "smallvec", 2030 + "thread_local", 2031 + "tracing", 2032 + "tracing-core", 2033 + "tracing-log", 2034 + ] 2035 + 2036 + [[package]] 2037 + name = "try-lock" 2038 + version = "0.2.5" 2039 + source = "registry+https://github.com/rust-lang/crates.io-index" 2040 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2041 + 2042 + [[package]] 2043 + name = "tungstenite" 2044 + version = "0.28.0" 2045 + source = "registry+https://github.com/rust-lang/crates.io-index" 2046 + checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" 2047 + dependencies = [ 2048 + "bytes", 2049 + "data-encoding", 2050 + "http", 2051 + "httparse", 2052 + "log", 2053 + "rand", 2054 + "sha1", 2055 + "thiserror 2.0.18", 2056 + "utf-8", 2057 + ] 2058 + 2059 + [[package]] 2060 + name = "typenum" 2061 + version = "1.19.0" 2062 + source = "registry+https://github.com/rust-lang/crates.io-index" 2063 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2064 + 2065 + [[package]] 2066 + name = "unicode-ident" 2067 + version = "1.0.24" 2068 + source = "registry+https://github.com/rust-lang/crates.io-index" 2069 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 2070 + 2071 + [[package]] 2072 + name = "url" 2073 + version = "2.5.8" 2074 + source = "registry+https://github.com/rust-lang/crates.io-index" 2075 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 2076 + dependencies = [ 2077 + "form_urlencoded", 2078 + "idna", 2079 + "percent-encoding", 2080 + "serde", 2081 + ] 2082 + 2083 + [[package]] 2084 + name = "utf-8" 2085 + version = "0.7.6" 2086 + source = "registry+https://github.com/rust-lang/crates.io-index" 2087 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2088 + 2089 + [[package]] 2090 + name = "utf8_iter" 2091 + version = "1.0.4" 2092 + source = "registry+https://github.com/rust-lang/crates.io-index" 2093 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2094 + 2095 + [[package]] 2096 + name = "utf8parse" 2097 + version = "0.2.2" 2098 + source = "registry+https://github.com/rust-lang/crates.io-index" 2099 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2100 + 2101 + [[package]] 2102 + name = "v_frame" 2103 + version = "0.3.9" 2104 + source = "registry+https://github.com/rust-lang/crates.io-index" 2105 + checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" 2106 + dependencies = [ 2107 + "aligned-vec", 2108 + "num-traits", 2109 + "wasm-bindgen", 2110 + ] 2111 + 2112 + [[package]] 2113 + name = "valuable" 2114 + version = "0.1.1" 2115 + source = "registry+https://github.com/rust-lang/crates.io-index" 2116 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2117 + 2118 + [[package]] 2119 + name = "version_check" 2120 + version = "0.9.5" 2121 + source = "registry+https://github.com/rust-lang/crates.io-index" 2122 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2123 + 2124 + [[package]] 2125 + name = "want" 2126 + version = "0.3.1" 2127 + source = "registry+https://github.com/rust-lang/crates.io-index" 2128 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 2129 + dependencies = [ 2130 + "try-lock", 2131 + ] 2132 + 2133 + [[package]] 2134 + name = "wasi" 2135 + version = "0.11.1+wasi-snapshot-preview1" 2136 + source = "registry+https://github.com/rust-lang/crates.io-index" 2137 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 2138 + 2139 + [[package]] 2140 + name = "wasip2" 2141 + version = "1.0.2+wasi-0.2.9" 2142 + source = "registry+https://github.com/rust-lang/crates.io-index" 2143 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" 2144 + dependencies = [ 2145 + "wit-bindgen", 2146 + ] 2147 + 2148 + [[package]] 2149 + name = "wasm-bindgen" 2150 + version = "0.2.108" 2151 + source = "registry+https://github.com/rust-lang/crates.io-index" 2152 + checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" 2153 + dependencies = [ 2154 + "cfg-if", 2155 + "once_cell", 2156 + "rustversion", 2157 + "wasm-bindgen-macro", 2158 + "wasm-bindgen-shared", 2159 + ] 2160 + 2161 + [[package]] 2162 + name = "wasm-bindgen-futures" 2163 + version = "0.4.58" 2164 + source = "registry+https://github.com/rust-lang/crates.io-index" 2165 + checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" 2166 + dependencies = [ 2167 + "cfg-if", 2168 + "futures-util", 2169 + "js-sys", 2170 + "once_cell", 2171 + "wasm-bindgen", 2172 + "web-sys", 2173 + ] 2174 + 2175 + [[package]] 2176 + name = "wasm-bindgen-macro" 2177 + version = "0.2.108" 2178 + source = "registry+https://github.com/rust-lang/crates.io-index" 2179 + checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" 2180 + dependencies = [ 2181 + "quote", 2182 + "wasm-bindgen-macro-support", 2183 + ] 2184 + 2185 + [[package]] 2186 + name = "wasm-bindgen-macro-support" 2187 + version = "0.2.108" 2188 + source = "registry+https://github.com/rust-lang/crates.io-index" 2189 + checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" 2190 + dependencies = [ 2191 + "bumpalo", 2192 + "proc-macro2", 2193 + "quote", 2194 + "syn", 2195 + "wasm-bindgen-shared", 2196 + ] 2197 + 2198 + [[package]] 2199 + name = "wasm-bindgen-shared" 2200 + version = "0.2.108" 2201 + source = "registry+https://github.com/rust-lang/crates.io-index" 2202 + checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" 2203 + dependencies = [ 2204 + "unicode-ident", 2205 + ] 2206 + 2207 + [[package]] 2208 + name = "web-sys" 2209 + version = "0.3.85" 2210 + source = "registry+https://github.com/rust-lang/crates.io-index" 2211 + checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" 2212 + dependencies = [ 2213 + "js-sys", 2214 + "wasm-bindgen", 2215 + ] 2216 + 2217 + [[package]] 2218 + name = "weezl" 2219 + version = "0.1.12" 2220 + source = "registry+https://github.com/rust-lang/crates.io-index" 2221 + checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" 2222 + 2223 + [[package]] 2224 + name = "which" 2225 + version = "8.0.0" 2226 + source = "registry+https://github.com/rust-lang/crates.io-index" 2227 + checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" 2228 + dependencies = [ 2229 + "env_home", 2230 + "rustix", 2231 + "winsafe", 2232 + ] 2233 + 2234 + [[package]] 2235 + name = "windows-link" 2236 + version = "0.1.3" 2237 + source = "registry+https://github.com/rust-lang/crates.io-index" 2238 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 2239 + 2240 + [[package]] 2241 + name = "windows-link" 2242 + version = "0.2.1" 2243 + source = "registry+https://github.com/rust-lang/crates.io-index" 2244 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 2245 + 2246 + [[package]] 2247 + name = "windows-registry" 2248 + version = "0.5.3" 2249 + source = "registry+https://github.com/rust-lang/crates.io-index" 2250 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 2251 + dependencies = [ 2252 + "windows-link 0.1.3", 2253 + "windows-result", 2254 + "windows-strings", 2255 + ] 2256 + 2257 + [[package]] 2258 + name = "windows-result" 2259 + version = "0.3.4" 2260 + source = "registry+https://github.com/rust-lang/crates.io-index" 2261 + checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 2262 + dependencies = [ 2263 + "windows-link 0.1.3", 2264 + ] 2265 + 2266 + [[package]] 2267 + name = "windows-strings" 2268 + version = "0.4.2" 2269 + source = "registry+https://github.com/rust-lang/crates.io-index" 2270 + checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 2271 + dependencies = [ 2272 + "windows-link 0.1.3", 2273 + ] 2274 + 2275 + [[package]] 2276 + name = "windows-sys" 2277 + version = "0.60.2" 2278 + source = "registry+https://github.com/rust-lang/crates.io-index" 2279 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 2280 + dependencies = [ 2281 + "windows-targets", 2282 + ] 2283 + 2284 + [[package]] 2285 + name = "windows-sys" 2286 + version = "0.61.2" 2287 + source = "registry+https://github.com/rust-lang/crates.io-index" 2288 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 2289 + dependencies = [ 2290 + "windows-link 0.2.1", 2291 + ] 2292 + 2293 + [[package]] 2294 + name = "windows-targets" 2295 + version = "0.53.5" 2296 + source = "registry+https://github.com/rust-lang/crates.io-index" 2297 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 2298 + dependencies = [ 2299 + "windows-link 0.2.1", 2300 + "windows_aarch64_gnullvm", 2301 + "windows_aarch64_msvc", 2302 + "windows_i686_gnu", 2303 + "windows_i686_gnullvm", 2304 + "windows_i686_msvc", 2305 + "windows_x86_64_gnu", 2306 + "windows_x86_64_gnullvm", 2307 + "windows_x86_64_msvc", 2308 + ] 2309 + 2310 + [[package]] 2311 + name = "windows_aarch64_gnullvm" 2312 + version = "0.53.1" 2313 + source = "registry+https://github.com/rust-lang/crates.io-index" 2314 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 2315 + 2316 + [[package]] 2317 + name = "windows_aarch64_msvc" 2318 + version = "0.53.1" 2319 + source = "registry+https://github.com/rust-lang/crates.io-index" 2320 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 2321 + 2322 + [[package]] 2323 + name = "windows_i686_gnu" 2324 + version = "0.53.1" 2325 + source = "registry+https://github.com/rust-lang/crates.io-index" 2326 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 2327 + 2328 + [[package]] 2329 + name = "windows_i686_gnullvm" 2330 + version = "0.53.1" 2331 + source = "registry+https://github.com/rust-lang/crates.io-index" 2332 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 2333 + 2334 + [[package]] 2335 + name = "windows_i686_msvc" 2336 + version = "0.53.1" 2337 + source = "registry+https://github.com/rust-lang/crates.io-index" 2338 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 2339 + 2340 + [[package]] 2341 + name = "windows_x86_64_gnu" 2342 + version = "0.53.1" 2343 + source = "registry+https://github.com/rust-lang/crates.io-index" 2344 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 2345 + 2346 + [[package]] 2347 + name = "windows_x86_64_gnullvm" 2348 + version = "0.53.1" 2349 + source = "registry+https://github.com/rust-lang/crates.io-index" 2350 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 2351 + 2352 + [[package]] 2353 + name = "windows_x86_64_msvc" 2354 + version = "0.53.1" 2355 + source = "registry+https://github.com/rust-lang/crates.io-index" 2356 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 2357 + 2358 + [[package]] 2359 + name = "winsafe" 2360 + version = "0.0.19" 2361 + source = "registry+https://github.com/rust-lang/crates.io-index" 2362 + checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" 2363 + 2364 + [[package]] 2365 + name = "wit-bindgen" 2366 + version = "0.51.0" 2367 + source = "registry+https://github.com/rust-lang/crates.io-index" 2368 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 2369 + 2370 + [[package]] 2371 + name = "writeable" 2372 + version = "0.6.2" 2373 + source = "registry+https://github.com/rust-lang/crates.io-index" 2374 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 2375 + 2376 + [[package]] 2377 + name = "y4m" 2378 + version = "0.8.0" 2379 + source = "registry+https://github.com/rust-lang/crates.io-index" 2380 + checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" 2381 + 2382 + [[package]] 2383 + name = "yoke" 2384 + version = "0.8.1" 2385 + source = "registry+https://github.com/rust-lang/crates.io-index" 2386 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 2387 + dependencies = [ 2388 + "stable_deref_trait", 2389 + "yoke-derive", 2390 + "zerofrom", 2391 + ] 2392 + 2393 + [[package]] 2394 + name = "yoke-derive" 2395 + version = "0.8.1" 2396 + source = "registry+https://github.com/rust-lang/crates.io-index" 2397 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 2398 + dependencies = [ 2399 + "proc-macro2", 2400 + "quote", 2401 + "syn", 2402 + "synstructure", 2403 + ] 2404 + 2405 + [[package]] 2406 + name = "zerocopy" 2407 + version = "0.8.39" 2408 + source = "registry+https://github.com/rust-lang/crates.io-index" 2409 + checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" 2410 + dependencies = [ 2411 + "zerocopy-derive", 2412 + ] 2413 + 2414 + [[package]] 2415 + name = "zerocopy-derive" 2416 + version = "0.8.39" 2417 + source = "registry+https://github.com/rust-lang/crates.io-index" 2418 + checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" 2419 + dependencies = [ 2420 + "proc-macro2", 2421 + "quote", 2422 + "syn", 2423 + ] 2424 + 2425 + [[package]] 2426 + name = "zerofrom" 2427 + version = "0.1.6" 2428 + source = "registry+https://github.com/rust-lang/crates.io-index" 2429 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2430 + dependencies = [ 2431 + "zerofrom-derive", 2432 + ] 2433 + 2434 + [[package]] 2435 + name = "zerofrom-derive" 2436 + version = "0.1.6" 2437 + source = "registry+https://github.com/rust-lang/crates.io-index" 2438 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2439 + dependencies = [ 2440 + "proc-macro2", 2441 + "quote", 2442 + "syn", 2443 + "synstructure", 2444 + ] 2445 + 2446 + [[package]] 2447 + name = "zerotrie" 2448 + version = "0.2.3" 2449 + source = "registry+https://github.com/rust-lang/crates.io-index" 2450 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 2451 + dependencies = [ 2452 + "displaydoc", 2453 + "yoke", 2454 + "zerofrom", 2455 + ] 2456 + 2457 + [[package]] 2458 + name = "zerovec" 2459 + version = "0.11.5" 2460 + source = "registry+https://github.com/rust-lang/crates.io-index" 2461 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 2462 + dependencies = [ 2463 + "yoke", 2464 + "zerofrom", 2465 + "zerovec-derive", 2466 + ] 2467 + 2468 + [[package]] 2469 + name = "zerovec-derive" 2470 + version = "0.11.2" 2471 + source = "registry+https://github.com/rust-lang/crates.io-index" 2472 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 2473 + dependencies = [ 2474 + "proc-macro2", 2475 + "quote", 2476 + "syn", 2477 + ] 2478 + 2479 + [[package]] 2480 + name = "zmij" 2481 + version = "1.0.21" 2482 + source = "registry+https://github.com/rust-lang/crates.io-index" 2483 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 2484 + 2485 + [[package]] 2486 + name = "zune-core" 2487 + version = "0.4.12" 2488 + source = "registry+https://github.com/rust-lang/crates.io-index" 2489 + checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" 2490 + 2491 + [[package]] 2492 + name = "zune-core" 2493 + version = "0.5.1" 2494 + source = "registry+https://github.com/rust-lang/crates.io-index" 2495 + checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" 2496 + 2497 + [[package]] 2498 + name = "zune-inflate" 2499 + version = "0.2.54" 2500 + source = "registry+https://github.com/rust-lang/crates.io-index" 2501 + checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" 2502 + dependencies = [ 2503 + "simd-adler32", 2504 + ] 2505 + 2506 + [[package]] 2507 + name = "zune-jpeg" 2508 + version = "0.4.21" 2509 + source = "registry+https://github.com/rust-lang/crates.io-index" 2510 + checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" 2511 + dependencies = [ 2512 + "zune-core 0.4.12", 2513 + ] 2514 + 2515 + [[package]] 2516 + name = "zune-jpeg" 2517 + version = "0.5.12" 2518 + source = "registry+https://github.com/rust-lang/crates.io-index" 2519 + checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" 2520 + dependencies = [ 2521 + "zune-core 0.5.1", 2522 + ]
+21
Cargo.toml
···
··· 1 + [package] 2 + name = "browser-stream" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [dependencies] 7 + anyhow = "1.0" 8 + base64 = "0.22" 9 + clap = { version = "4.5", features = ["derive"] } 10 + chromiumoxide = "0.8" 11 + chromiumoxide_cdp = "0.8" 12 + futures = "0.3" 13 + image = { version = "0.25", default-features = true, features = ["jpeg", "png"] } 14 + thiserror = "2.0" 15 + tokio = { version = "1.47", features = ["full"] } 16 + tracing = "0.1" 17 + tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 18 + url = "2.5" 19 + 20 + [dev-dependencies] 21 + assert_matches = "1.5"
+113
Dockerfile
···
··· 1 + FROM rust:1.89-bookworm AS builder 2 + WORKDIR /app 3 + 4 + COPY Cargo.toml Cargo.lock ./ 5 + COPY src ./src 6 + 7 + RUN cargo build --release 8 + 9 + FROM debian:bookworm-slim AS sidecar-fetch 10 + ARG TARGETARCH 11 + 12 + RUN arch="${TARGETARCH:-amd64}" \ 13 + && test "${arch}" = "amd64" || (echo "full image sidecars currently support linux/amd64 only" >&2 && exit 1) 14 + 15 + RUN apt-get update \ 16 + && apt-get install -y --no-install-recommends \ 17 + ca-certificates \ 18 + curl \ 19 + jq \ 20 + unzip \ 21 + && rm -rf /var/lib/apt/lists/* 22 + 23 + RUN set -euo pipefail; \ 24 + mkdir -p /out/sidecar/chromium /out/sidecar/ffmpeg; \ 25 + chromium_manifest_url="https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; \ 26 + chromium_url="$(curl -fsSL "${chromium_manifest_url}" | jq -r '.channels.Stable.downloads["chrome-headless-shell"][] | select(.platform == "linux64") | .url')"; \ 27 + test -n "${chromium_url}" && test "${chromium_url}" != "null"; \ 28 + curl -fL "${chromium_url}" -o /tmp/chromium.zip; \ 29 + unzip -q /tmp/chromium.zip -d /tmp/chromium; \ 30 + chromium_source="$(find /tmp/chromium -type f -name chrome-headless-shell | head -n 1)"; \ 31 + test -n "${chromium_source}"; \ 32 + chromium_root="$(dirname "${chromium_source}")"; \ 33 + cp -R "${chromium_root}"/. /out/sidecar/chromium/; \ 34 + cp "${chromium_source}" /out/sidecar/chromium/headless_shell; \ 35 + chmod +x /out/sidecar/chromium/headless_shell; \ 36 + ffmpeg_url="https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip"; \ 37 + curl -fL "${ffmpeg_url}" -o /tmp/ffmpeg.zip; \ 38 + unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg; \ 39 + ffmpeg_source="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n 1)"; \ 40 + test -n "${ffmpeg_source}"; \ 41 + cp "${ffmpeg_source}" /out/sidecar/ffmpeg/ffmpeg; \ 42 + chmod +x /out/sidecar/ffmpeg/ffmpeg 43 + 44 + FROM debian:bookworm-slim AS runtime-base 45 + WORKDIR /app 46 + 47 + RUN apt-get update \ 48 + && apt-get install -y --no-install-recommends \ 49 + ca-certificates \ 50 + fonts-liberation \ 51 + libasound2 \ 52 + libatk-bridge2.0-0 \ 53 + libatk1.0-0 \ 54 + libc6 \ 55 + libcairo2 \ 56 + libcups2 \ 57 + libdbus-1-3 \ 58 + libdrm2 \ 59 + libexpat1 \ 60 + libgbm1 \ 61 + libglib2.0-0 \ 62 + libgtk-3-0 \ 63 + libnspr4 \ 64 + libnss3 \ 65 + libpango-1.0-0 \ 66 + libu2f-udev \ 67 + libx11-6 \ 68 + libx11-xcb1 \ 69 + libxcb1 \ 70 + libxcomposite1 \ 71 + libxcursor1 \ 72 + libxdamage1 \ 73 + libxext6 \ 74 + libxfixes3 \ 75 + libxi6 \ 76 + libxkbcommon0 \ 77 + libxrandr2 \ 78 + libxrender1 \ 79 + libxshmfence1 \ 80 + xdg-utils \ 81 + && rm -rf /var/lib/apt/lists/* 82 + 83 + COPY --from=builder /app/target/release/browser-stream /usr/local/bin/browser-stream 84 + COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh 85 + RUN chmod +x /usr/local/bin/entrypoint.sh 86 + 87 + RUN groupadd --system browserstream \ 88 + && useradd --system --gid browserstream --create-home --home-dir /home/browserstream browserstream 89 + 90 + ENV RUST_LOG=info 91 + ENV BROWSER_STREAM_NO_SANDBOX=1 92 + 93 + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 94 + 95 + FROM runtime-base AS full 96 + COPY --from=sidecar-fetch /out/sidecar /opt/sidecar 97 + RUN chown -R browserstream:browserstream /opt/sidecar 98 + 99 + ENV BROWSER_STREAM_CHROMIUM_PATH=/opt/sidecar/chromium/headless_shell 100 + ENV BROWSER_STREAM_FFMPEG_PATH=/opt/sidecar/ffmpeg/ffmpeg 101 + 102 + USER browserstream 103 + 104 + FROM runtime-base AS slim 105 + RUN apt-get update \ 106 + && apt-get install -y --no-install-recommends \ 107 + chromium \ 108 + ffmpeg \ 109 + && rm -rf /var/lib/apt/lists/* 110 + 111 + USER browserstream 112 + 113 + FROM slim AS default
+177
README.md
···
··· 1 + # browser-stream 2 + 3 + Rust CLI to stream a fullscreen browser-rendered website to RTMP/RTMPS using bundled Chromium Headless Shell and FFmpeg sidecars. 4 + 5 + ## Expected sidecar layout 6 + 7 + The binary resolves sidecars relative to itself: 8 + 9 + - `../sidecar/chromium/headless_shell` (`headless_shell.exe` on Windows) 10 + - `../sidecar/ffmpeg/ffmpeg` (`ffmpeg.exe` on Windows) 11 + 12 + You can override both paths with CLI flags: 13 + 14 + - `--chromium-path /abs/path/to/headless_shell` 15 + - `--ffmpeg-path /abs/path/to/ffmpeg` 16 + 17 + ## Local Sidecar Setup 18 + 19 + When running with `cargo run`, the executable lives under `target/`, so sidecars are expected under `target/sidecar`. 20 + 21 + Fetch sidecars for your host platform: 22 + 23 + ```bash 24 + ./scripts/fetch-sidecars.sh 25 + ``` 26 + 27 + Windows (PowerShell): 28 + 29 + ```powershell 30 + ./scripts/fetch-sidecars.ps1 31 + ``` 32 + 33 + ## Usage 34 + 35 + ```bash 36 + cargo run -- \ 37 + --url https://example.com \ 38 + --width 1920 \ 39 + --height 1080 \ 40 + --fps 30 \ 41 + --bitrate-kbps 4500 \ 42 + --keyint-sec 1 \ 43 + --x264-opts bframes=0 \ 44 + --rtmp-url rtmp://live.example.com/app \ 45 + --stream-key mystream 46 + ``` 47 + 48 + Equivalent full-output form: 49 + 50 + ```bash 51 + cargo run -- \ 52 + --url https://example.com \ 53 + --output rtmp://live.example.com/app/mystream 54 + ``` 55 + 56 + ## Docker Compose 57 + 58 + `docker-compose.yml` defines two image variants: 59 + 60 + - `slim` (default service `browser-stream`): uses system `chromium` + `ffmpeg` packages in the container. 61 + - `full` (service `browser-stream-full`): bundles sidecar binaries in image at `/opt/sidecar/...`. 62 + 63 + Build and run `slim` (default): 64 + 65 + ```bash 66 + docker compose up --build 67 + ``` 68 + 69 + Build and run `full`: 70 + 71 + ```bash 72 + docker compose --profile full up --build browser-stream-full 73 + ``` 74 + 75 + Or build directly with Docker targets: 76 + 77 + ```bash 78 + docker build --target slim -t browser-stream:slim . 79 + docker build --target full -t browser-stream:full . 80 + ``` 81 + 82 + Configure with environment variables (for example in `.env`): 83 + 84 + ```bash 85 + WEBSITE_URL=https://example.com 86 + # Option A: full output URL 87 + OUTPUT=rtmp://live.example.com/app/mystream 88 + 89 + # Option B: split URL + key (used when OUTPUT is empty) 90 + RTMP_URL=rtmp://live.example.com/app 91 + STREAM_KEY=mystream 92 + 93 + WIDTH=1920 94 + HEIGHT=1080 95 + FPS=30 96 + BITRATE_KBPS=4500 97 + KEYINT_SEC=1 98 + X264_OPTS=bframes=0 99 + RETRIES=5 100 + RETRY_BACKOFF_MS=1000 101 + STARTUP_DELAY_MS=2000 102 + FRAME_TIMEOUT_MS=30000 103 + NO_AUDIO=0 104 + VERBOSE=0 105 + ``` 106 + 107 + Binary resolution in containers: 108 + 109 + - `slim`: auto-detects system `chromium`/`chromium-browser` and `ffmpeg`. 110 + - `full`: uses bundled sidecars via `BROWSER_STREAM_CHROMIUM_PATH` and `BROWSER_STREAM_FFMPEG_PATH`. 111 + 112 + No sidecar downloads are required for either image. 113 + Both images set `BROWSER_STREAM_NO_SANDBOX=1` for Chromium compatibility in containers. 114 + 115 + ## Defaults 116 + 117 + - `width=1920` 118 + - `height=1080` 119 + - `fps=30` 120 + - `bitrate-kbps=4500` 121 + - `keyint-sec=1` 122 + - `x264-opts=bframes=0` 123 + - `retries=5` 124 + - `retry-backoff-ms=1000` 125 + - `startup-delay-ms=2000` 126 + - `frame-timeout-ms=30000` 127 + - `no-audio=false` (silent audio track enabled by default) 128 + 129 + ## Notes 130 + 131 + - v1 supports public HTTP(S) website URLs. 132 + - A silent audio track is included by default for RTMP compatibility. 133 + - Use `--no-audio` to disable audio. 134 + - RTMP output supports `rtmp://` and `rtmps://`. 135 + - On stream failure, the app retries a fixed number of times and exits non-zero once exhausted. 136 + - Runtime controls while streaming: 137 + - type `r` or `refresh` then press Enter to manually reload the page. 138 + - type `h` or `help` then press Enter to print controls. 139 + - with compose, run foreground (`docker compose up`) to send commands directly via stdin. 140 + 141 + ## GitHub Release Bundling 142 + 143 + Workflow: `.github/workflows/build-and-release.yml` 144 + 145 + - Builds release binaries for: 146 + - macOS arm64 147 + - Linux x86_64 148 + - Windows x86_64 149 + - Downloads platform sidecars and packages archives in this layout: 150 + - `bin/browser-stream[.exe]` 151 + - `sidecar/chromium/headless_shell[.exe]` 152 + - `sidecar/ffmpeg/ffmpeg[.exe]` 153 + - Uploads build artifacts on PR/push. 154 + - Publishes release assets automatically on tags like `v0.1.0`. 155 + 156 + ## GHCR Docker Publish 157 + 158 + Workflow: `.github/workflows/docker-publish.yml` 159 + 160 + - Builds and publishes both Docker variants: 161 + - `slim`: `linux/amd64` and `linux/arm64` 162 + - `full`: `linux/amd64` (sidecar availability) 163 + - Publishes to GitHub Container Registry on push to `main` and version tags (`v*`). 164 + - Uses image name: 165 + - `ghcr.io/<owner>/<repo>` 166 + - Example for this repo: `ghcr.io/mmattbtw/browser-stream` 167 + - Tags include: 168 + - `latest-slim` / `latest-full` (default branch) 169 + - branch/tag refs with suffixes (`-slim`, `-full`) 170 + - `sha-<commit>-slim` / `sha-<commit>-full` 171 + 172 + Example: 173 + 174 + ```bash 175 + docker pull ghcr.io/mmattbtw/browser-stream:latest-slim 176 + docker pull ghcr.io/mmattbtw/browser-stream:latest-full 177 + ```
+42
docker-compose.yml
···
··· 1 + x-browser-stream-env: &browser-stream-env 2 + WEBSITE_URL: ${WEBSITE_URL:-https://example.com} 3 + OUTPUT: ${OUTPUT:-} 4 + RTMP_URL: ${RTMP_URL:-rtmp://live.example.com/app} 5 + STREAM_KEY: ${STREAM_KEY:-changeme} 6 + WIDTH: ${WIDTH:-1920} 7 + HEIGHT: ${HEIGHT:-1080} 8 + FPS: ${FPS:-30} 9 + BITRATE_KBPS: ${BITRATE_KBPS:-4500} 10 + KEYINT_SEC: ${KEYINT_SEC:-1} 11 + X264_OPTS: ${X264_OPTS:-bframes=0} 12 + RETRIES: ${RETRIES:-5} 13 + RETRY_BACKOFF_MS: ${RETRY_BACKOFF_MS:-1000} 14 + STARTUP_DELAY_MS: ${STARTUP_DELAY_MS:-2000} 15 + FRAME_TIMEOUT_MS: ${FRAME_TIMEOUT_MS:-30000} 16 + NO_AUDIO: ${NO_AUDIO:-0} 17 + VERBOSE: ${VERBOSE:-0} 18 + 19 + x-browser-stream-common: &browser-stream-common 20 + restart: unless-stopped 21 + environment: *browser-stream-env 22 + stdin_open: true 23 + tty: true 24 + 25 + services: 26 + browser-stream: 27 + <<: *browser-stream-common 28 + build: 29 + context: . 30 + dockerfile: Dockerfile 31 + target: slim 32 + image: browser-stream:slim 33 + 34 + browser-stream-full: 35 + <<: *browser-stream-common 36 + build: 37 + context: . 38 + dockerfile: Dockerfile 39 + target: full 40 + image: browser-stream:full 41 + profiles: 42 + - full
+74
docker/entrypoint.sh
···
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + if [[ -z "${WEBSITE_URL:-}" ]]; then 5 + echo "WEBSITE_URL is required" >&2 6 + exit 1 7 + fi 8 + 9 + if [[ -z "${OUTPUT:-}" && ( -z "${RTMP_URL:-}" || -z "${STREAM_KEY:-}" ) ]]; then 10 + echo "Provide either OUTPUT or both RTMP_URL and STREAM_KEY" >&2 11 + exit 1 12 + fi 13 + 14 + if [[ -n "${BROWSER_STREAM_CHROMIUM_PATH:-}" ]]; then 15 + CHROMIUM_BIN="${BROWSER_STREAM_CHROMIUM_PATH}" 16 + elif command -v chromium >/dev/null 2>&1; then 17 + CHROMIUM_BIN="$(command -v chromium)" 18 + elif command -v chromium-browser >/dev/null 2>&1; then 19 + CHROMIUM_BIN="$(command -v chromium-browser)" 20 + else 21 + echo "Could not find chromium binary in container" >&2 22 + exit 1 23 + fi 24 + 25 + if [[ ! -x "${CHROMIUM_BIN}" ]]; then 26 + echo "Configured chromium binary is not executable: ${CHROMIUM_BIN}" >&2 27 + exit 1 28 + fi 29 + 30 + if [[ -n "${BROWSER_STREAM_FFMPEG_PATH:-}" ]]; then 31 + FFMPEG_BIN="${BROWSER_STREAM_FFMPEG_PATH}" 32 + elif command -v ffmpeg >/dev/null 2>&1; then 33 + FFMPEG_BIN="$(command -v ffmpeg)" 34 + else 35 + echo "Could not find ffmpeg binary in container" >&2 36 + exit 1 37 + fi 38 + 39 + if [[ ! -x "${FFMPEG_BIN}" ]]; then 40 + echo "Configured ffmpeg binary is not executable: ${FFMPEG_BIN}" >&2 41 + exit 1 42 + fi 43 + 44 + args=( 45 + --url "${WEBSITE_URL}" 46 + --width "${WIDTH:-1920}" 47 + --height "${HEIGHT:-1080}" 48 + --fps "${FPS:-30}" 49 + --bitrate-kbps "${BITRATE_KBPS:-4500}" 50 + --keyint-sec "${KEYINT_SEC:-1}" 51 + --x264-opts "${X264_OPTS:-bframes=0}" 52 + --retries "${RETRIES:-5}" 53 + --retry-backoff-ms "${RETRY_BACKOFF_MS:-1000}" 54 + --startup-delay-ms "${STARTUP_DELAY_MS:-2000}" 55 + --frame-timeout-ms "${FRAME_TIMEOUT_MS:-30000}" 56 + --chromium-path "${CHROMIUM_BIN}" 57 + --ffmpeg-path "${FFMPEG_BIN}" 58 + ) 59 + 60 + if [[ -n "${OUTPUT:-}" ]]; then 61 + args+=(--output "${OUTPUT}") 62 + else 63 + args+=(--rtmp-url "${RTMP_URL}" --stream-key "${STREAM_KEY}") 64 + fi 65 + 66 + if [[ "${VERBOSE:-0}" == "1" || "${VERBOSE:-false}" == "true" ]]; then 67 + args+=(--verbose) 68 + fi 69 + 70 + if [[ "${NO_AUDIO:-0}" == "1" || "${NO_AUDIO:-false}" == "true" ]]; then 71 + args+=(--no-audio) 72 + fi 73 + 74 + exec /usr/local/bin/browser-stream "${args[@]}" "$@"
+60
scripts/fetch-sidecars.ps1
···
··· 1 + param( 2 + [string]$Destination = "target/sidecar" 3 + ) 4 + 5 + $ErrorActionPreference = "Stop" 6 + 7 + Write-Host "Fetching Chromium headless shell and FFmpeg sidecars..." 8 + 9 + New-Item -ItemType Directory -Force -Path "$Destination/chromium" | Out-Null 10 + New-Item -ItemType Directory -Force -Path "$Destination/ffmpeg" | Out-Null 11 + 12 + $tmpRoot = Join-Path $env:TEMP ("browser-stream-sidecars-" + [guid]::NewGuid().ToString()) 13 + New-Item -ItemType Directory -Path $tmpRoot | Out-Null 14 + 15 + try { 16 + $manifestUrl = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json" 17 + $manifest = Invoke-RestMethod -Uri $manifestUrl 18 + 19 + $chromiumEntry = $manifest.channels.Stable.downloads.'chrome-headless-shell' | 20 + Where-Object { $_.platform -eq 'win64' } | 21 + Select-Object -First 1 22 + 23 + if (-not $chromiumEntry) { 24 + throw "Could not resolve chrome-headless-shell download URL for win64" 25 + } 26 + 27 + $chromiumZip = Join-Path $tmpRoot "chromium.zip" 28 + Invoke-WebRequest -Uri $chromiumEntry.url -OutFile $chromiumZip 29 + Expand-Archive -Path $chromiumZip -DestinationPath (Join-Path $tmpRoot "chromium") -Force 30 + 31 + $chromiumExe = Get-ChildItem -Path (Join-Path $tmpRoot "chromium") -Recurse -File -Filter "chrome-headless-shell.exe" | Select-Object -First 1 32 + if (-not $chromiumExe) { 33 + throw "Could not find chrome-headless-shell.exe in downloaded Chromium archive" 34 + } 35 + 36 + $chromiumRoot = $chromiumExe.Directory.FullName 37 + Copy-Item -Path (Join-Path $chromiumRoot '*') -Destination (Join-Path $Destination "chromium") -Recurse -Force 38 + Copy-Item -Path $chromiumExe.FullName -Destination (Join-Path $Destination "chromium/headless_shell.exe") -Force 39 + 40 + $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" 41 + $ffmpegZip = Join-Path $tmpRoot "ffmpeg.zip" 42 + Invoke-WebRequest -Uri $ffmpegUrl -OutFile $ffmpegZip 43 + Expand-Archive -Path $ffmpegZip -DestinationPath (Join-Path $tmpRoot "ffmpeg") -Force 44 + 45 + $ffmpegExe = Get-ChildItem -Path (Join-Path $tmpRoot "ffmpeg") -Recurse -File -Filter "ffmpeg.exe" | Select-Object -First 1 46 + if (-not $ffmpegExe) { 47 + throw "Could not find ffmpeg.exe in downloaded FFmpeg archive" 48 + } 49 + 50 + Copy-Item -Path $ffmpegExe.FullName -Destination (Join-Path $Destination "ffmpeg/ffmpeg.exe") -Force 51 + 52 + Write-Host "Sidecars installed:" 53 + Write-Host " Chromium: $Destination/chromium/headless_shell.exe" 54 + Write-Host " FFmpeg: $Destination/ffmpeg/ffmpeg.exe" 55 + } 56 + finally { 57 + if (Test-Path $tmpRoot) { 58 + Remove-Item -Path $tmpRoot -Recurse -Force 59 + } 60 + }
+115
scripts/fetch-sidecars.sh
···
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + DESTINATION="target/sidecar" 5 + 6 + while [[ $# -gt 0 ]]; do 7 + case "$1" in 8 + --destination) 9 + DESTINATION="$2" 10 + shift 2 11 + ;; 12 + -h|--help) 13 + cat <<'USAGE' 14 + Fetch bundled Chromium Headless Shell and FFmpeg sidecars for the current platform. 15 + 16 + Usage: 17 + ./scripts/fetch-sidecars.sh [--destination <dir>] 18 + 19 + Defaults: 20 + destination: target/sidecar 21 + USAGE 22 + exit 0 23 + ;; 24 + *) 25 + echo "Unknown argument: $1" >&2 26 + exit 1 27 + ;; 28 + esac 29 + done 30 + 31 + if ! command -v curl >/dev/null 2>&1; then 32 + echo "curl is required" >&2 33 + exit 1 34 + fi 35 + 36 + if ! command -v jq >/dev/null 2>&1; then 37 + echo "jq is required" >&2 38 + exit 1 39 + fi 40 + 41 + if ! command -v unzip >/dev/null 2>&1; then 42 + echo "unzip is required" >&2 43 + exit 1 44 + fi 45 + 46 + uname_s="$(uname -s)" 47 + uname_m="$(uname -m)" 48 + 49 + chromium_platform="" 50 + ffmpeg_url="" 51 + chromium_exec_name="chrome-headless-shell" 52 + ffmpeg_exec_name="ffmpeg" 53 + 54 + case "${uname_s}:${uname_m}" in 55 + Darwin:arm64) 56 + chromium_platform="mac-arm64" 57 + ffmpeg_url="https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" 58 + ;; 59 + Linux:x86_64) 60 + chromium_platform="linux64" 61 + ffmpeg_url="https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip" 62 + ;; 63 + *) 64 + echo "Unsupported platform ${uname_s}/${uname_m}. This script supports macOS arm64 and Linux x86_64." >&2 65 + exit 1 66 + ;; 67 + esac 68 + 69 + chrome_manifest_url="https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json" 70 + chromium_url="$(curl -fsSL "$chrome_manifest_url" | jq -r --arg platform "$chromium_platform" '.channels.Stable.downloads["chrome-headless-shell"][] | select(.platform == $platform) | .url')" 71 + 72 + if [[ -z "$chromium_url" || "$chromium_url" == "null" ]]; then 73 + echo "Failed to resolve Chromium headless shell URL for platform ${chromium_platform}" >&2 74 + exit 1 75 + fi 76 + 77 + tmp_dir="$(mktemp -d)" 78 + trap 'rm -rf "$tmp_dir"' EXIT 79 + 80 + mkdir -p "$DESTINATION/chromium" "$DESTINATION/ffmpeg" 81 + 82 + chromium_zip="$tmp_dir/chromium.zip" 83 + ffmpeg_zip="$tmp_dir/ffmpeg.zip" 84 + 85 + printf 'Downloading Chromium headless shell (%s)\n' "$chromium_platform" 86 + curl -fL "$chromium_url" -o "$chromium_zip" 87 + unzip -q "$chromium_zip" -d "$tmp_dir/chromium" 88 + 89 + chromium_source="$(find "$tmp_dir/chromium" -type f -name "$chromium_exec_name" | head -n 1)" 90 + if [[ -z "$chromium_source" ]]; then 91 + echo "Could not find ${chromium_exec_name} inside Chromium archive" >&2 92 + exit 1 93 + fi 94 + 95 + chromium_root="$(dirname "$chromium_source")" 96 + cp -R "$chromium_root"/. "$DESTINATION/chromium/" 97 + cp "$chromium_source" "$DESTINATION/chromium/headless_shell" 98 + chmod +x "$DESTINATION/chromium/headless_shell" 99 + 100 + printf 'Downloading FFmpeg\n' 101 + curl -fL "$ffmpeg_url" -o "$ffmpeg_zip" 102 + unzip -q "$ffmpeg_zip" -d "$tmp_dir/ffmpeg" 103 + 104 + ffmpeg_source="$(find "$tmp_dir/ffmpeg" -type f -name "$ffmpeg_exec_name" | head -n 1)" 105 + if [[ -z "$ffmpeg_source" ]]; then 106 + echo "Could not find ${ffmpeg_exec_name} inside FFmpeg archive" >&2 107 + exit 1 108 + fi 109 + 110 + cp "$ffmpeg_source" "$DESTINATION/ffmpeg/ffmpeg" 111 + chmod +x "$DESTINATION/ffmpeg/ffmpeg" 112 + 113 + printf 'Sidecars installed:\n' 114 + printf ' Chromium: %s\n' "$DESTINATION/chromium/headless_shell" 115 + printf ' FFmpeg: %s\n' "$DESTINATION/ffmpeg/ffmpeg"
+289
src/chromium.rs
···
··· 1 + use std::path::{Path, PathBuf}; 2 + use std::time::Duration; 3 + 4 + use anyhow::{Context, Result, anyhow}; 5 + use chromiumoxide::browser::{Browser, BrowserConfig}; 6 + use chromiumoxide::cdp::browser_protocol::page::{ 7 + EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat, StartScreencastParams, 8 + StopScreencastParams, 9 + }; 10 + use chromiumoxide::handler::viewport::Viewport; 11 + use futures::StreamExt; 12 + use tokio::io::{AsyncBufReadExt, BufReader}; 13 + use tokio::sync::mpsc; 14 + use tokio::time::MissedTickBehavior; 15 + use tracing::{debug, error, info, warn}; 16 + 17 + use crate::cli::AppConfig; 18 + use crate::encoder::FfmpegEncoder; 19 + use crate::error::RuntimeError; 20 + use crate::frame::{RgbFrame, decode_screencast_frame}; 21 + 22 + pub async fn stream_browser_to_encoder( 23 + config: &AppConfig, 24 + chromium_path: &Path, 25 + encoder: &mut FfmpegEncoder, 26 + ) -> Result<()> { 27 + let viewport = Viewport { 28 + width: config.width, 29 + height: config.height, 30 + device_scale_factor: Some(1.0), 31 + emulating_mobile: false, 32 + is_landscape: config.width >= config.height, 33 + has_touch: false, 34 + }; 35 + 36 + let mut browser_builder = BrowserConfig::builder() 37 + .chrome_executable(chromium_path) 38 + .window_size(config.width, config.height) 39 + .new_headless_mode() 40 + .viewport(viewport) 41 + .arg("--autoplay-policy=no-user-gesture-required") 42 + .arg("--disable-background-timer-throttling") 43 + .arg("--disable-backgrounding-occluded-windows") 44 + .arg("--disable-renderer-backgrounding"); 45 + 46 + if no_sandbox_from_env() { 47 + browser_builder = browser_builder.no_sandbox(); 48 + } 49 + 50 + let browser_config = browser_builder 51 + .build() 52 + .map_err(|err| anyhow!("failed to build browser config: {err}"))?; 53 + 54 + info!(chromium = %chromium_path.display(), "starting chromium"); 55 + 56 + let (mut browser, mut handler) = Browser::launch(browser_config) 57 + .await 58 + .context("failed to launch chromium")?; 59 + 60 + let handler_task = tokio::spawn(async move { 61 + while let Some(item) = handler.next().await { 62 + if let Err(err) = item { 63 + error!("chromium handler error: {err}"); 64 + break; 65 + } 66 + } 67 + }); 68 + 69 + let page = browser 70 + .new_page("about:blank") 71 + .await 72 + .context("failed to create page")?; 73 + 74 + page.goto(config.website_url.as_str()) 75 + .await 76 + .with_context(|| format!("failed loading {}", config.website_url))?; 77 + 78 + // `goto` waits for page load completion. Delay further for dynamic JS/CSS settling. 79 + tokio::time::sleep(Duration::from_millis(config.startup_delay_ms)).await; 80 + 81 + let mut frame_events = page 82 + .event_listener::<EventScreencastFrame>() 83 + .await 84 + .context("failed to register screencast event listener")?; 85 + 86 + let start_params = StartScreencastParams::builder() 87 + .format(StartScreencastFormat::Jpeg) 88 + .quality(80_i64) 89 + .max_width(i64::from(config.width)) 90 + .max_height(i64::from(config.height)) 91 + .every_nth_frame(1_i64) 92 + .build(); 93 + 94 + page.execute(start_params) 95 + .await 96 + .context("failed to start screencast")?; 97 + 98 + info!("runtime controls: type `r` then Enter to refresh the page"); 99 + 100 + let mut control_rx = spawn_control_listener(); 101 + let frame_interval = Duration::from_secs_f64(1.0_f64 / f64::from(config.fps)); 102 + let mut frame_tick = tokio::time::interval(frame_interval); 103 + frame_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); 104 + frame_tick.tick().await; 105 + let mut stats_tick = tokio::time::interval(Duration::from_secs(5)); 106 + stats_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); 107 + stats_tick.tick().await; 108 + 109 + let first_frame_timeout = tokio::time::sleep(Duration::from_millis(config.frame_timeout_ms)); 110 + tokio::pin!(first_frame_timeout); 111 + let mut latest_frame: Option<RgbFrame> = None; 112 + let mut decoded_frames: u64 = 0; 113 + let mut encoded_frames: u64 = 0; 114 + 115 + let stream_result: Result<()> = async { 116 + loop { 117 + tokio::select! { 118 + biased; 119 + _ = frame_tick.tick() => { 120 + if let Some(frame) = latest_frame.as_ref() { 121 + encoder.write_frame(frame).await?; 122 + encoded_frames = encoded_frames.saturating_add(1); 123 + } 124 + } 125 + maybe_event = frame_events.next() => { 126 + let event = maybe_event.context("screencast event stream ended unexpectedly")?; 127 + 128 + page.execute(ScreencastFrameAckParams::new(event.session_id)) 129 + .await 130 + .context("failed to ack screencast frame")?; 131 + 132 + let frame = decode_screencast_frame(event.data.as_ref(), config.width, config.height) 133 + .context("failed to decode screencast frame")?; 134 + 135 + if latest_frame.is_none() { 136 + info!("received first screencast frame"); 137 + // Prime ffmpeg immediately so it can initialize output without waiting for the first tick. 138 + encoder.write_frame(&frame).await?; 139 + encoded_frames = encoded_frames.saturating_add(1); 140 + } 141 + decoded_frames = decoded_frames.saturating_add(1); 142 + latest_frame = Some(frame); 143 + } 144 + _ = stats_tick.tick() => { 145 + debug!( 146 + decoded_frames, 147 + encoded_frames, 148 + has_frame = latest_frame.is_some(), 149 + "streaming stats" 150 + ); 151 + } 152 + command = control_rx.recv() => { 153 + match command { 154 + Some(ControlCommand::Refresh) => { 155 + page.reload() 156 + .await 157 + .context("manual refresh failed")?; 158 + info!("manual refresh applied"); 159 + } 160 + Some(ControlCommand::Help) => { 161 + info!("runtime controls: `r` or `refresh` reloads the page"); 162 + } 163 + None => { 164 + // stdin closed; continue streaming without runtime controls. 165 + } 166 + } 167 + } 168 + _ = tokio::signal::ctrl_c() => { 169 + return Err(RuntimeError::ShutdownRequested.into()); 170 + } 171 + _ = &mut first_frame_timeout, if latest_frame.is_none() => { 172 + return Err(RuntimeError::ScreencastTimeout.into()); 173 + } 174 + } 175 + } 176 + } 177 + .await; 178 + 179 + if let Err(err) = page.execute(StopScreencastParams::default()).await { 180 + warn!("failed to stop screencast cleanly: {err}"); 181 + } 182 + 183 + if let Err(err) = browser.close().await { 184 + warn!("failed to close browser cleanly: {err}"); 185 + } 186 + if let Err(err) = browser.wait().await { 187 + warn!("failed to wait for browser process: {err}"); 188 + } 189 + 190 + handler_task.abort(); 191 + 192 + stream_result 193 + } 194 + 195 + fn no_sandbox_from_env() -> bool { 196 + match std::env::var("BROWSER_STREAM_NO_SANDBOX") { 197 + Ok(value) => parse_truthy(&value), 198 + Err(_) => false, 199 + } 200 + } 201 + 202 + fn parse_truthy(value: &str) -> bool { 203 + matches!( 204 + value.trim().to_ascii_lowercase().as_str(), 205 + "1" | "true" | "yes" 206 + ) 207 + } 208 + 209 + pub fn chromium_executable_name() -> &'static str { 210 + if cfg!(target_os = "windows") { 211 + "headless_shell.exe" 212 + } else { 213 + "headless_shell" 214 + } 215 + } 216 + 217 + pub fn default_chromium_sidecar_path(exe_dir: &Path) -> PathBuf { 218 + exe_dir 219 + .join("..") 220 + .join("sidecar") 221 + .join("chromium") 222 + .join(chromium_executable_name()) 223 + } 224 + 225 + #[derive(Debug, Copy, Clone)] 226 + enum ControlCommand { 227 + Refresh, 228 + Help, 229 + } 230 + 231 + fn parse_control_command(input: &str) -> Option<ControlCommand> { 232 + match input.trim().to_ascii_lowercase().as_str() { 233 + "r" | "refresh" => Some(ControlCommand::Refresh), 234 + "h" | "help" => Some(ControlCommand::Help), 235 + _ => None, 236 + } 237 + } 238 + 239 + fn spawn_control_listener() -> mpsc::UnboundedReceiver<ControlCommand> { 240 + let (tx, rx) = mpsc::unbounded_channel(); 241 + 242 + tokio::spawn(async move { 243 + let mut lines = BufReader::new(tokio::io::stdin()).lines(); 244 + while let Ok(Some(line)) = lines.next_line().await { 245 + if let Some(command) = parse_control_command(&line) { 246 + if tx.send(command).is_err() { 247 + break; 248 + } 249 + } 250 + } 251 + }); 252 + 253 + rx 254 + } 255 + 256 + #[cfg(test)] 257 + mod tests { 258 + use super::{ControlCommand, parse_control_command, parse_truthy}; 259 + 260 + #[test] 261 + fn parses_refresh_shortcut() { 262 + assert!(matches!( 263 + parse_control_command("r"), 264 + Some(ControlCommand::Refresh) 265 + )); 266 + } 267 + 268 + #[test] 269 + fn parses_refresh_word() { 270 + assert!(matches!( 271 + parse_control_command(" refresh "), 272 + Some(ControlCommand::Refresh) 273 + )); 274 + } 275 + 276 + #[test] 277 + fn ignores_unknown_commands() { 278 + assert!(parse_control_command("noop").is_none()); 279 + } 280 + 281 + #[test] 282 + fn truthy_parser() { 283 + assert!(parse_truthy("true")); 284 + assert!(parse_truthy("1")); 285 + assert!(parse_truthy("YES")); 286 + assert!(!parse_truthy("0")); 287 + assert!(!parse_truthy("false")); 288 + } 289 + }
+144
src/cli.rs
···
··· 1 + use std::path::PathBuf; 2 + 3 + use clap::Parser; 4 + use url::Url; 5 + 6 + use crate::error::ConfigError; 7 + 8 + #[derive(Debug, Parser, Clone)] 9 + #[command( 10 + name = "browser-stream", 11 + version, 12 + about = "Stream a browser page to RTMP" 13 + )] 14 + pub struct CliArgs { 15 + #[arg(long)] 16 + pub url: String, 17 + 18 + #[arg(long, default_value_t = 1920)] 19 + pub width: u32, 20 + 21 + #[arg(long, default_value_t = 1080)] 22 + pub height: u32, 23 + 24 + #[arg(long, default_value_t = 30)] 25 + pub fps: u32, 26 + 27 + #[arg(long, default_value_t = 4500)] 28 + pub bitrate_kbps: u32, 29 + 30 + #[arg(long, default_value_t = 1)] 31 + pub keyint_sec: u32, 32 + 33 + #[arg(long, default_value = "bframes=0")] 34 + pub x264_opts: String, 35 + 36 + #[arg(long)] 37 + pub rtmp_url: Option<String>, 38 + 39 + #[arg(long)] 40 + pub stream_key: Option<String>, 41 + 42 + #[arg(long)] 43 + pub output: Option<String>, 44 + 45 + #[arg(long, default_value_t = 5)] 46 + pub retries: u32, 47 + 48 + #[arg(long, default_value_t = 1000)] 49 + pub retry_backoff_ms: u64, 50 + 51 + #[arg(long, default_value_t = 2000)] 52 + pub startup_delay_ms: u64, 53 + 54 + #[arg(long, default_value_t = 30000)] 55 + pub frame_timeout_ms: u64, 56 + 57 + #[arg(long, default_value_t = false)] 58 + pub no_audio: bool, 59 + 60 + #[arg(long)] 61 + pub ffmpeg_path: Option<PathBuf>, 62 + 63 + #[arg(long)] 64 + pub chromium_path: Option<PathBuf>, 65 + 66 + #[arg(long, default_value_t = false)] 67 + pub verbose: bool, 68 + } 69 + 70 + #[derive(Debug, Clone)] 71 + pub struct AppConfig { 72 + pub website_url: Url, 73 + pub width: u32, 74 + pub height: u32, 75 + pub fps: u32, 76 + pub bitrate_kbps: u32, 77 + pub keyint_sec: u32, 78 + pub x264_opts: String, 79 + pub output: String, 80 + pub retries: u32, 81 + pub retry_backoff_ms: u64, 82 + pub startup_delay_ms: u64, 83 + pub frame_timeout_ms: u64, 84 + pub no_audio: bool, 85 + pub ffmpeg_path: Option<PathBuf>, 86 + pub chromium_path: Option<PathBuf>, 87 + pub verbose: bool, 88 + } 89 + 90 + impl CliArgs { 91 + pub fn into_config(self) -> Result<AppConfig, ConfigError> { 92 + validate_range("width", self.width as u64, 16, u32::MAX as u64)?; 93 + validate_range("height", self.height as u64, 16, u32::MAX as u64)?; 94 + validate_range("fps", self.fps as u64, 1, 120)?; 95 + validate_range( 96 + "bitrate-kbps", 97 + self.bitrate_kbps as u64, 98 + 100, 99 + u32::MAX as u64, 100 + )?; 101 + validate_range("keyint-sec", self.keyint_sec as u64, 1, 60)?; 102 + validate_range("frame-timeout-ms", self.frame_timeout_ms, 1000, u64::MAX)?; 103 + 104 + let website_url = 105 + Url::parse(&self.url).map_err(|_| ConfigError::InvalidWebsiteUrl(self.url.clone()))?; 106 + match website_url.scheme() { 107 + "http" | "https" => {} 108 + other => return Err(ConfigError::UnsupportedWebsiteScheme(other.to_string())), 109 + } 110 + 111 + let output = crate::rtmp::build_output(self.output, self.rtmp_url, self.stream_key)?; 112 + 113 + Ok(AppConfig { 114 + website_url, 115 + width: self.width, 116 + height: self.height, 117 + fps: self.fps, 118 + bitrate_kbps: self.bitrate_kbps, 119 + keyint_sec: self.keyint_sec, 120 + x264_opts: self.x264_opts, 121 + output, 122 + retries: self.retries, 123 + retry_backoff_ms: self.retry_backoff_ms, 124 + startup_delay_ms: self.startup_delay_ms, 125 + frame_timeout_ms: self.frame_timeout_ms, 126 + no_audio: self.no_audio, 127 + ffmpeg_path: self.ffmpeg_path, 128 + chromium_path: self.chromium_path, 129 + verbose: self.verbose, 130 + }) 131 + } 132 + } 133 + 134 + fn validate_range(field: &'static str, actual: u64, min: u64, max: u64) -> Result<(), ConfigError> { 135 + if actual < min || actual > max { 136 + return Err(ConfigError::OutOfRange { 137 + field, 138 + min, 139 + max, 140 + actual, 141 + }); 142 + } 143 + Ok(()) 144 + }
+217
src/encoder.rs
···
··· 1 + use std::path::{Path, PathBuf}; 2 + use std::process::ExitStatus; 3 + 4 + use anyhow::{Context, Result, bail}; 5 + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 6 + use tokio::process::{Child, ChildStdin, Command}; 7 + use tokio::task::JoinHandle; 8 + use tracing::{debug, info, warn}; 9 + 10 + use crate::frame::RgbFrame; 11 + 12 + #[derive(Debug, Clone)] 13 + pub struct EncoderSettings { 14 + pub width: u32, 15 + pub height: u32, 16 + pub fps: u32, 17 + pub bitrate_kbps: u32, 18 + pub keyint_sec: u32, 19 + pub x264_opts: String, 20 + pub output: String, 21 + pub include_silent_audio: bool, 22 + pub ffmpeg_path: PathBuf, 23 + } 24 + 25 + pub fn build_ffmpeg_args(settings: &EncoderSettings) -> Vec<String> { 26 + build_ffmpeg_args_with_loglevel(settings, "warning") 27 + } 28 + 29 + pub fn build_ffmpeg_args_with_loglevel(settings: &EncoderSettings, loglevel: &str) -> Vec<String> { 30 + let keyint = settings.fps.saturating_mul(settings.keyint_sec).max(1); 31 + let bufsize = settings.bitrate_kbps.saturating_mul(2); 32 + 33 + let mut args = vec![ 34 + "-hide_banner".to_string(), 35 + "-loglevel".to_string(), 36 + loglevel.to_string(), 37 + "-stats_period".to_string(), 38 + "5".to_string(), 39 + "-stats".to_string(), 40 + "-f".to_string(), 41 + "rawvideo".to_string(), 42 + "-pix_fmt".to_string(), 43 + "rgb24".to_string(), 44 + "-s".to_string(), 45 + format!("{}x{}", settings.width, settings.height), 46 + "-r".to_string(), 47 + settings.fps.to_string(), 48 + "-i".to_string(), 49 + "-".to_string(), 50 + ]; 51 + 52 + if settings.include_silent_audio { 53 + args.extend([ 54 + "-f".to_string(), 55 + "lavfi".to_string(), 56 + "-i".to_string(), 57 + "anullsrc=r=48000:cl=stereo".to_string(), 58 + ]); 59 + } 60 + 61 + args.extend([ 62 + "-c:v".to_string(), 63 + "libx264".to_string(), 64 + "-preset".to_string(), 65 + "veryfast".to_string(), 66 + "-pix_fmt".to_string(), 67 + "yuv420p".to_string(), 68 + "-b:v".to_string(), 69 + format!("{}k", settings.bitrate_kbps), 70 + "-maxrate".to_string(), 71 + format!("{}k", settings.bitrate_kbps), 72 + "-bufsize".to_string(), 73 + format!("{}k", bufsize), 74 + "-g".to_string(), 75 + keyint.to_string(), 76 + "-keyint_min".to_string(), 77 + keyint.to_string(), 78 + "-x264-params".to_string(), 79 + settings.x264_opts.clone(), 80 + ]); 81 + 82 + if settings.include_silent_audio { 83 + args.extend([ 84 + "-c:a".to_string(), 85 + "aac".to_string(), 86 + "-b:a".to_string(), 87 + "128k".to_string(), 88 + "-ar".to_string(), 89 + "48000".to_string(), 90 + "-ac".to_string(), 91 + "2".to_string(), 92 + ]); 93 + } else { 94 + args.push("-an".to_string()); 95 + } 96 + 97 + args.extend(["-f".to_string(), "flv".to_string(), settings.output.clone()]); 98 + 99 + args 100 + } 101 + 102 + #[derive(Debug)] 103 + pub struct FfmpegEncoder { 104 + child: Child, 105 + stdin: ChildStdin, 106 + stderr_task: JoinHandle<()>, 107 + } 108 + 109 + impl FfmpegEncoder { 110 + pub async fn spawn(settings: &EncoderSettings, verbose: bool) -> Result<Self> { 111 + let args = 112 + build_ffmpeg_args_with_loglevel(settings, if verbose { "info" } else { "warning" }); 113 + 114 + info!( 115 + ffmpeg = %settings.ffmpeg_path.display(), 116 + output = %settings.output, 117 + "starting ffmpeg" 118 + ); 119 + 120 + let mut cmd = Command::new(&settings.ffmpeg_path); 121 + cmd.args(&args) 122 + .stdin(std::process::Stdio::piped()) 123 + .stderr(std::process::Stdio::piped()) 124 + .stdout(std::process::Stdio::null()); 125 + 126 + let mut child = cmd.spawn().with_context(|| { 127 + format!( 128 + "failed to spawn ffmpeg from {}", 129 + settings.ffmpeg_path.display() 130 + ) 131 + })?; 132 + 133 + let stdin = child.stdin.take().context("ffmpeg stdin unavailable")?; 134 + 135 + let stderr = child.stderr.take().context("ffmpeg stderr unavailable")?; 136 + 137 + let stderr_task = tokio::spawn(async move { 138 + let mut lines = BufReader::new(stderr).lines(); 139 + while let Ok(Some(line)) = lines.next_line().await { 140 + if verbose { 141 + info!(target: "ffmpeg", "{line}"); 142 + } else { 143 + debug!(target: "ffmpeg", "{line}"); 144 + } 145 + } 146 + }); 147 + 148 + Ok(Self { 149 + child, 150 + stdin, 151 + stderr_task, 152 + }) 153 + } 154 + 155 + pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> { 156 + self.child 157 + .try_wait() 158 + .context("failed to poll ffmpeg process") 159 + } 160 + 161 + pub async fn write_frame(&mut self, frame: &RgbFrame) -> Result<()> { 162 + if frame.width == 0 || frame.height == 0 { 163 + bail!("invalid frame dimensions {}x{}", frame.width, frame.height); 164 + } 165 + 166 + if let Some(status) = self.try_wait()? { 167 + bail!("ffmpeg exited early with status {status}"); 168 + } 169 + 170 + self.stdin 171 + .write_all(&frame.data) 172 + .await 173 + .context("failed writing frame to ffmpeg stdin")?; 174 + 175 + Ok(()) 176 + } 177 + 178 + pub async fn kill_and_wait(&mut self) { 179 + match self.child.kill().await { 180 + Ok(()) => {} 181 + Err(err) => warn!("failed to kill ffmpeg: {err}"), 182 + } 183 + 184 + match self.child.wait().await { 185 + Ok(status) => debug!("ffmpeg exited after kill: {status}"), 186 + Err(err) => warn!("failed waiting on ffmpeg: {err}"), 187 + } 188 + } 189 + 190 + pub async fn wait_for_exit(mut self) -> Result<ExitStatus> { 191 + drop(self.stdin); 192 + let status = self 193 + .child 194 + .wait() 195 + .await 196 + .context("failed waiting for ffmpeg exit")?; 197 + 198 + self.stderr_task.abort(); 199 + Ok(status) 200 + } 201 + } 202 + 203 + pub fn ffmpeg_executable_name() -> &'static str { 204 + if cfg!(target_os = "windows") { 205 + "ffmpeg.exe" 206 + } else { 207 + "ffmpeg" 208 + } 209 + } 210 + 211 + pub fn default_ffmpeg_sidecar_path(exe_dir: &Path) -> PathBuf { 212 + exe_dir 213 + .join("..") 214 + .join("sidecar") 215 + .join("ffmpeg") 216 + .join(ffmpeg_executable_name()) 217 + }
+32
src/error.rs
···
··· 1 + use std::path::PathBuf; 2 + 3 + use thiserror::Error; 4 + 5 + #[derive(Debug, Error)] 6 + pub enum ConfigError { 7 + #[error("website URL must use http or https, got `{0}`")] 8 + UnsupportedWebsiteScheme(String), 9 + #[error("invalid website URL `{0}`")] 10 + InvalidWebsiteUrl(String), 11 + #[error("`{field}` out of range: got {actual}, expected {min}..={max}")] 12 + OutOfRange { 13 + field: &'static str, 14 + min: u64, 15 + max: u64, 16 + actual: u64, 17 + }, 18 + #[error(transparent)] 19 + Rtmp(#[from] crate::rtmp::RtmpError), 20 + } 21 + 22 + #[derive(Debug, Error)] 23 + pub enum RuntimeError { 24 + #[error("shutdown requested")] 25 + ShutdownRequested, 26 + #[error("timed out waiting for screencast frames")] 27 + ScreencastTimeout, 28 + #[error( 29 + "missing sidecar binary `{name}` at `{path}`. Provide an explicit override path or place sidecars at this location. For local development, fetch sidecars with `./scripts/fetch-sidecars.sh` (macOS/Linux) or `./scripts/fetch-sidecars.ps1` (Windows). Supported packaged targets: macOS arm64, Linux x86_64, Windows x86_64" 30 + )] 31 + MissingSidecar { name: &'static str, path: PathBuf }, 32 + }
+37
src/frame.rs
···
··· 1 + use anyhow::{Context, Result}; 2 + use base64::Engine; 3 + 4 + #[derive(Debug, Clone)] 5 + pub struct RgbFrame { 6 + pub width: u32, 7 + pub height: u32, 8 + pub data: Vec<u8>, 9 + } 10 + 11 + pub fn decode_screencast_frame( 12 + encoded_data: &str, 13 + target_width: u32, 14 + target_height: u32, 15 + ) -> Result<RgbFrame> { 16 + let bytes = base64::engine::general_purpose::STANDARD 17 + .decode(encoded_data) 18 + .context("failed to decode CDP frame payload")?; 19 + 20 + let img = image::load_from_memory(&bytes).context("failed to decode image bytes")?; 21 + let normalized = if img.width() == target_width && img.height() == target_height { 22 + img 23 + } else { 24 + img.resize_exact( 25 + target_width, 26 + target_height, 27 + image::imageops::FilterType::Triangle, 28 + ) 29 + }; 30 + 31 + let rgb = normalized.to_rgb8(); 32 + Ok(RgbFrame { 33 + width: target_width, 34 + height: target_height, 35 + data: rgb.into_raw(), 36 + }) 37 + }
+7
src/lib.rs
···
··· 1 + pub mod chromium; 2 + pub mod cli; 3 + pub mod encoder; 4 + pub mod error; 5 + pub mod frame; 6 + pub mod retry; 7 + pub mod rtmp;
+235
src/main.rs
···
··· 1 + use std::path::PathBuf; 2 + use std::time::Duration; 3 + 4 + use anyhow::{Context, Result, bail}; 5 + use clap::Parser; 6 + use tracing::{info, warn}; 7 + 8 + use browser_stream::chromium; 9 + use browser_stream::cli::{AppConfig, CliArgs}; 10 + use browser_stream::encoder::{self, EncoderSettings, FfmpegEncoder}; 11 + use browser_stream::error::RuntimeError; 12 + use browser_stream::retry::RetryPolicy; 13 + 14 + #[derive(Debug, Clone)] 15 + struct RuntimePaths { 16 + ffmpeg: PathBuf, 17 + chromium: PathBuf, 18 + } 19 + 20 + #[tokio::main] 21 + async fn main() -> Result<()> { 22 + let args = CliArgs::parse(); 23 + init_tracing(args.verbose); 24 + 25 + let config = args.into_config()?; 26 + let runtime_paths = resolve_runtime_paths(&config)?; 27 + let retry_policy = RetryPolicy::new( 28 + config.retries, 29 + Duration::from_millis(config.retry_backoff_ms), 30 + ); 31 + 32 + run_with_retry(&config, &runtime_paths, &retry_policy).await 33 + } 34 + 35 + async fn run_with_retry( 36 + config: &AppConfig, 37 + runtime_paths: &RuntimePaths, 38 + retry_policy: &RetryPolicy, 39 + ) -> Result<()> { 40 + let mut failures = 0_u32; 41 + 42 + loop { 43 + let attempt = failures + 1; 44 + info!(attempt, "starting stream attempt"); 45 + 46 + let result = run_once(config, runtime_paths).await; 47 + 48 + match result { 49 + Ok(()) => return Ok(()), 50 + Err(err) => { 51 + if is_shutdown_error(&err) { 52 + info!("shutdown requested, exiting"); 53 + // Force process termination in case any background runtime task/thread 54 + // holds the process open after graceful shutdown. 55 + std::process::exit(0); 56 + } 57 + 58 + failures = failures.saturating_add(1); 59 + if !retry_policy.should_retry(failures) { 60 + return Err(err.context(format!( 61 + "stream failed after {attempt} attempt(s) with {failures} failure(s)" 62 + ))); 63 + } 64 + 65 + warn!( 66 + attempt, 67 + failures, 68 + backoff_ms = retry_policy.backoff.as_millis(), 69 + error = %err, 70 + "stream attempt failed; retrying" 71 + ); 72 + 73 + tokio::select! { 74 + _ = tokio::time::sleep(retry_policy.backoff) => {} 75 + _ = tokio::signal::ctrl_c() => { 76 + info!("shutdown requested during retry backoff, exiting"); 77 + return Ok(()); 78 + } 79 + } 80 + } 81 + } 82 + } 83 + } 84 + 85 + async fn run_once(config: &AppConfig, runtime_paths: &RuntimePaths) -> Result<()> { 86 + let settings = EncoderSettings { 87 + width: config.width, 88 + height: config.height, 89 + fps: config.fps, 90 + bitrate_kbps: config.bitrate_kbps, 91 + keyint_sec: config.keyint_sec, 92 + x264_opts: config.x264_opts.clone(), 93 + output: config.output.clone(), 94 + include_silent_audio: !config.no_audio, 95 + ffmpeg_path: runtime_paths.ffmpeg.clone(), 96 + }; 97 + 98 + let mut encoder = FfmpegEncoder::spawn(&settings, config.verbose).await?; 99 + 100 + let stream_result = 101 + chromium::stream_browser_to_encoder(config, &runtime_paths.chromium, &mut encoder).await; 102 + 103 + match stream_result { 104 + Ok(()) => { 105 + let status = encoder.wait_for_exit().await?; 106 + if !status.success() { 107 + bail!("ffmpeg exited with status {status}"); 108 + } 109 + Ok(()) 110 + } 111 + Err(err) => { 112 + encoder.kill_and_wait().await; 113 + Err(err) 114 + } 115 + } 116 + } 117 + 118 + fn resolve_runtime_paths(config: &AppConfig) -> Result<RuntimePaths> { 119 + let current_exe = 120 + std::env::current_exe().context("failed to determine current executable path")?; 121 + let exe_dir = current_exe 122 + .parent() 123 + .context("failed to determine current executable directory")?; 124 + 125 + let ffmpeg_path = resolve_ffmpeg_path(config.ffmpeg_path.clone(), exe_dir)?; 126 + 127 + let chromium_path = resolve_binary_path( 128 + config.chromium_path.clone(), 129 + chromium::default_chromium_sidecar_path(exe_dir), 130 + "headless_shell", 131 + )?; 132 + 133 + Ok(RuntimePaths { 134 + ffmpeg: ffmpeg_path, 135 + chromium: chromium_path, 136 + }) 137 + } 138 + 139 + fn resolve_binary_path( 140 + override_path: Option<PathBuf>, 141 + default_path: PathBuf, 142 + name: &'static str, 143 + ) -> Result<PathBuf> { 144 + let candidate = override_path.unwrap_or(default_path); 145 + 146 + if candidate.is_file() { 147 + return Ok(candidate); 148 + } 149 + 150 + Err(RuntimeError::MissingSidecar { 151 + name, 152 + path: candidate, 153 + } 154 + .into()) 155 + } 156 + 157 + fn resolve_ffmpeg_path( 158 + override_path: Option<PathBuf>, 159 + exe_dir: &std::path::Path, 160 + ) -> Result<PathBuf> { 161 + if let Some(path) = override_path { 162 + return resolve_binary_path(Some(path), PathBuf::new(), "ffmpeg"); 163 + } 164 + 165 + let sidecar = encoder::default_ffmpeg_sidecar_path(exe_dir); 166 + let system = find_in_path(encoder::ffmpeg_executable_name()); 167 + 168 + if cfg!(target_os = "macos") { 169 + if let Some(system_path) = system { 170 + info!( 171 + ffmpeg = %system_path.display(), 172 + "using system ffmpeg on macOS (preferred over sidecar)" 173 + ); 174 + return Ok(system_path); 175 + } 176 + } 177 + 178 + if sidecar.is_file() { 179 + return Ok(sidecar); 180 + } 181 + 182 + if let Some(system_path) = find_in_path(encoder::ffmpeg_executable_name()) { 183 + info!( 184 + ffmpeg = %system_path.display(), 185 + "using system ffmpeg from PATH" 186 + ); 187 + return Ok(system_path); 188 + } 189 + 190 + Err(RuntimeError::MissingSidecar { 191 + name: "ffmpeg", 192 + path: sidecar, 193 + } 194 + .into()) 195 + } 196 + 197 + fn find_in_path(executable_name: &str) -> Option<PathBuf> { 198 + let path_var = std::env::var_os("PATH")?; 199 + let candidates = std::env::split_paths(&path_var); 200 + 201 + for dir in candidates { 202 + let direct = dir.join(executable_name); 203 + if direct.is_file() { 204 + return Some(direct); 205 + } 206 + 207 + if cfg!(target_os = "windows") { 208 + let exe = dir.join(format!("{executable_name}.exe")); 209 + if exe.is_file() { 210 + return Some(exe); 211 + } 212 + } 213 + } 214 + 215 + None 216 + } 217 + 218 + fn is_shutdown_error(err: &anyhow::Error) -> bool { 219 + err.downcast_ref::<RuntimeError>() 220 + .is_some_and(|runtime| matches!(runtime, RuntimeError::ShutdownRequested)) 221 + } 222 + 223 + fn init_tracing(verbose: bool) { 224 + let filter = if verbose { 225 + tracing_subscriber::EnvFilter::new("info,browser_stream=debug,ffmpeg=info") 226 + } else { 227 + tracing_subscriber::EnvFilter::try_from_default_env() 228 + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) 229 + }; 230 + 231 + let _ = tracing_subscriber::fmt() 232 + .with_env_filter(filter) 233 + .with_target(true) 234 + .try_init(); 235 + }
+20
src/retry.rs
···
··· 1 + use std::time::Duration; 2 + 3 + #[derive(Debug, Clone)] 4 + pub struct RetryPolicy { 5 + pub max_retries: u32, 6 + pub backoff: Duration, 7 + } 8 + 9 + impl RetryPolicy { 10 + pub fn new(max_retries: u32, backoff: Duration) -> Self { 11 + Self { 12 + max_retries, 13 + backoff, 14 + } 15 + } 16 + 17 + pub fn should_retry(&self, failures_so_far: u32) -> bool { 18 + failures_so_far <= self.max_retries 19 + } 20 + }
+54
src/rtmp.rs
···
··· 1 + use thiserror::Error; 2 + use url::Url; 3 + 4 + #[derive(Debug, Error, PartialEq)] 5 + pub enum RtmpError { 6 + #[error("provide either `--output` or both `--rtmp-url` and `--stream-key`")] 7 + MissingDestination, 8 + #[error("stream key cannot be empty")] 9 + EmptyStreamKey, 10 + #[error("invalid RTMP output URL `{0}`")] 11 + InvalidOutputUrl(String), 12 + #[error("RTMP URL scheme must be `rtmp` or `rtmps`, got `{0}`")] 13 + InvalidScheme(String), 14 + } 15 + 16 + pub fn build_output( 17 + output: Option<String>, 18 + rtmp_url: Option<String>, 19 + stream_key: Option<String>, 20 + ) -> Result<String, RtmpError> { 21 + if let Some(full_output) = output { 22 + let trimmed = full_output.trim(); 23 + validate_output_url(trimmed)?; 24 + return Ok(trimmed.to_string()); 25 + } 26 + 27 + match (rtmp_url, stream_key) { 28 + (Some(base), Some(key)) => { 29 + let normalized_key = normalize_stream_key(&key)?; 30 + let merged = format!("{}/{}", base.trim_end_matches('/'), normalized_key); 31 + validate_output_url(&merged)?; 32 + Ok(merged) 33 + } 34 + _ => Err(RtmpError::MissingDestination), 35 + } 36 + } 37 + 38 + fn normalize_stream_key(raw: &str) -> Result<String, RtmpError> { 39 + let key = raw.trim().trim_start_matches('/').trim(); 40 + if key.is_empty() { 41 + return Err(RtmpError::EmptyStreamKey); 42 + } 43 + 44 + Ok(key.to_string()) 45 + } 46 + 47 + fn validate_output_url(candidate: &str) -> Result<(), RtmpError> { 48 + let parsed = 49 + Url::parse(candidate).map_err(|_| RtmpError::InvalidOutputUrl(candidate.to_string()))?; 50 + match parsed.scheme() { 51 + "rtmp" | "rtmps" => Ok(()), 52 + other => Err(RtmpError::InvalidScheme(other.to_string())), 53 + } 54 + }
+120
tests/cli.rs
···
··· 1 + use assert_matches::assert_matches; 2 + use clap::Parser; 3 + 4 + use browser_stream::cli::CliArgs; 5 + use browser_stream::error::ConfigError; 6 + use browser_stream::rtmp::RtmpError; 7 + 8 + #[test] 9 + fn parses_defaults_and_required_fields() { 10 + let args = CliArgs::try_parse_from([ 11 + "browser-stream", 12 + "--url", 13 + "https://example.com", 14 + "--output", 15 + "rtmp://live.example.com/app/stream", 16 + ]) 17 + .expect("cli parse should succeed"); 18 + 19 + let config = args.into_config().expect("config should validate"); 20 + 21 + assert_eq!(config.width, 1920); 22 + assert_eq!(config.height, 1080); 23 + assert_eq!(config.fps, 30); 24 + assert_eq!(config.bitrate_kbps, 4500); 25 + assert_eq!(config.keyint_sec, 1); 26 + assert_eq!(config.x264_opts, "bframes=0"); 27 + assert_eq!(config.retries, 5); 28 + assert_eq!(config.retry_backoff_ms, 1000); 29 + assert_eq!(config.startup_delay_ms, 2000); 30 + assert_eq!(config.frame_timeout_ms, 30000); 31 + assert!(!config.no_audio); 32 + } 33 + 34 + #[test] 35 + fn requires_destination_settings() { 36 + let args = CliArgs::try_parse_from(["browser-stream", "--url", "https://example.com"]) 37 + .expect("cli parse should succeed"); 38 + 39 + let err = args.into_config().expect_err("validation should fail"); 40 + assert_matches!(err, ConfigError::Rtmp(RtmpError::MissingDestination)); 41 + } 42 + 43 + #[test] 44 + fn output_precedence_wins_over_split_fields() { 45 + let args = CliArgs::try_parse_from([ 46 + "browser-stream", 47 + "--url", 48 + "https://example.com", 49 + "--output", 50 + "rtmps://primary.example.com/app/final", 51 + "--rtmp-url", 52 + "rtmp://secondary.example.com/app", 53 + "--stream-key", 54 + "secondary", 55 + ]) 56 + .expect("cli parse should succeed"); 57 + 58 + let config = args.into_config().expect("config should validate"); 59 + assert_eq!(config.output, "rtmps://primary.example.com/app/final"); 60 + } 61 + 62 + #[test] 63 + fn rejects_non_http_website_scheme() { 64 + let args = CliArgs::try_parse_from([ 65 + "browser-stream", 66 + "--url", 67 + "file:///tmp/index.html", 68 + "--output", 69 + "rtmp://live.example.com/app/key", 70 + ]) 71 + .expect("cli parse should succeed"); 72 + 73 + let err = args.into_config().expect_err("validation should fail"); 74 + assert_matches!(err, ConfigError::UnsupportedWebsiteScheme(s) if s == "file"); 75 + } 76 + 77 + #[test] 78 + fn rejects_out_of_range_fps() { 79 + let args = CliArgs::try_parse_from([ 80 + "browser-stream", 81 + "--url", 82 + "https://example.com", 83 + "--output", 84 + "rtmp://live.example.com/app/key", 85 + "--fps", 86 + "121", 87 + ]) 88 + .expect("cli parse should succeed"); 89 + 90 + let err = args.into_config().expect_err("validation should fail"); 91 + assert_matches!( 92 + err, 93 + ConfigError::OutOfRange { field, min: 1, max: 120, actual: 121 } if field == "fps" 94 + ); 95 + } 96 + 97 + #[test] 98 + fn rejects_out_of_range_frame_timeout() { 99 + let args = CliArgs::try_parse_from([ 100 + "browser-stream", 101 + "--url", 102 + "https://example.com", 103 + "--output", 104 + "rtmp://live.example.com/app/key", 105 + "--frame-timeout-ms", 106 + "500", 107 + ]) 108 + .expect("cli parse should succeed"); 109 + 110 + let err = args.into_config().expect_err("validation should fail"); 111 + assert_matches!( 112 + err, 113 + ConfigError::OutOfRange { 114 + field, 115 + min: 1000, 116 + max, 117 + actual: 500 118 + } if field == "frame-timeout-ms" && max == u64::MAX 119 + ); 120 + }
+80
tests/ffmpeg_cmd.rs
···
··· 1 + use std::path::PathBuf; 2 + 3 + use browser_stream::encoder::{EncoderSettings, build_ffmpeg_args}; 4 + 5 + #[test] 6 + fn derives_keyint_from_fps_and_seconds() { 7 + let settings = EncoderSettings { 8 + width: 1280, 9 + height: 720, 10 + fps: 30, 11 + bitrate_kbps: 2500, 12 + keyint_sec: 2, 13 + x264_opts: "bframes=0".to_string(), 14 + output: "rtmp://live.example.com/app/key".to_string(), 15 + include_silent_audio: true, 16 + ffmpeg_path: PathBuf::from("/tmp/ffmpeg"), 17 + }; 18 + 19 + let args = build_ffmpeg_args(&settings); 20 + 21 + assert_pair(&args, "-g", "60"); 22 + assert_pair(&args, "-keyint_min", "60"); 23 + } 24 + 25 + #[test] 26 + fn includes_cbr_like_flags() { 27 + let settings = EncoderSettings { 28 + width: 1920, 29 + height: 1080, 30 + fps: 60, 31 + bitrate_kbps: 4500, 32 + keyint_sec: 1, 33 + x264_opts: "bframes=0".to_string(), 34 + output: "rtmps://live.example.com/app/key".to_string(), 35 + include_silent_audio: true, 36 + ffmpeg_path: PathBuf::from("/tmp/ffmpeg"), 37 + }; 38 + 39 + let args = build_ffmpeg_args(&settings); 40 + 41 + assert_pair(&args, "-b:v", "4500k"); 42 + assert_pair(&args, "-maxrate", "4500k"); 43 + assert_pair(&args, "-bufsize", "9000k"); 44 + } 45 + 46 + #[test] 47 + fn passes_x264_opts_and_output() { 48 + let settings = EncoderSettings { 49 + width: 1920, 50 + height: 1080, 51 + fps: 30, 52 + bitrate_kbps: 3000, 53 + keyint_sec: 1, 54 + x264_opts: "bframes=0:scenecut=0".to_string(), 55 + output: "rtmp://live.example.com/app/key".to_string(), 56 + include_silent_audio: true, 57 + ffmpeg_path: PathBuf::from("/tmp/ffmpeg"), 58 + }; 59 + 60 + let args = build_ffmpeg_args(&settings); 61 + 62 + assert_pair(&args, "-x264-params", "bframes=0:scenecut=0"); 63 + assert_eq!( 64 + args.last().expect("args should not be empty"), 65 + "rtmp://live.example.com/app/key" 66 + ); 67 + } 68 + 69 + fn assert_pair(args: &[String], flag: &str, value: &str) { 70 + let index = args 71 + .iter() 72 + .position(|item| item == flag) 73 + .expect("flag should exist in arg list"); 74 + 75 + let next = args 76 + .get(index + 1) 77 + .expect("flag should have a following value"); 78 + 79 + assert_eq!(next, value); 80 + }
+13
tests/frame.rs
···
··· 1 + use browser_stream::frame::decode_screencast_frame; 2 + 3 + #[test] 4 + fn decodes_and_resizes_frame() { 5 + // 1x1 red PNG 6 + let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg=="; 7 + 8 + let frame = decode_screencast_frame(png_base64, 2, 2).expect("decode should work"); 9 + 10 + assert_eq!(frame.width, 2); 11 + assert_eq!(frame.height, 2); 12 + assert_eq!(frame.data.len(), 2 * 2 * 3); 13 + }
+12
tests/retry.rs
···
··· 1 + use std::time::Duration; 2 + 3 + use browser_stream::retry::RetryPolicy; 4 + 5 + #[test] 6 + fn retries_up_to_configured_limit() { 7 + let policy = RetryPolicy::new(5, Duration::from_millis(100)); 8 + 9 + assert!(policy.should_retry(1)); 10 + assert!(policy.should_retry(5)); 11 + assert!(!policy.should_retry(6)); 12 + }
+59
tests/rtmp.rs
···
··· 1 + use assert_matches::assert_matches; 2 + 3 + use browser_stream::rtmp::{RtmpError, build_output}; 4 + 5 + #[test] 6 + fn builds_output_from_split_fields() { 7 + let output = build_output( 8 + None, 9 + Some("rtmp://live.example.com/app".to_string()), 10 + Some("streamkey123".to_string()), 11 + ) 12 + .expect("build should succeed"); 13 + 14 + assert_eq!(output, "rtmp://live.example.com/app/streamkey123"); 15 + } 16 + 17 + #[test] 18 + fn trims_slashes_and_spaces_in_stream_key() { 19 + let output = build_output( 20 + None, 21 + Some("rtmp://live.example.com/app/".to_string()), 22 + Some(" /abc123 ".to_string()), 23 + ) 24 + .expect("build should succeed"); 25 + 26 + assert_eq!(output, "rtmp://live.example.com/app/abc123"); 27 + } 28 + 29 + #[test] 30 + fn output_flag_takes_precedence() { 31 + let output = build_output( 32 + Some("rtmps://primary.example.com/live/final".to_string()), 33 + Some("rtmp://secondary.example.com/app".to_string()), 34 + Some("secondary".to_string()), 35 + ) 36 + .expect("build should succeed"); 37 + 38 + assert_eq!(output, "rtmps://primary.example.com/live/final"); 39 + } 40 + 41 + #[test] 42 + fn rejects_invalid_scheme() { 43 + let err = build_output(Some("https://example.com/not-rtmp".to_string()), None, None) 44 + .expect_err("should fail"); 45 + 46 + assert_matches!(err, RtmpError::InvalidScheme(s) if s == "https"); 47 + } 48 + 49 + #[test] 50 + fn rejects_empty_key() { 51 + let err = build_output( 52 + None, 53 + Some("rtmp://live.example.com/app".to_string()), 54 + Some(" / ".to_string()), 55 + ) 56 + .expect_err("should fail"); 57 + 58 + assert_matches!(err, RtmpError::EmptyStreamKey); 59 + }