[
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\npermissions:\n  contents: write\n\njobs:\n  release:\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-24.04\n            artifact: dotmatrix-linux-amd64\n          - os: macos-15\n            artifact: dotmatrix-darwin-arm64\n          - os: macos-15-intel\n            artifact: dotmatrix-darwin-amd64\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: '1.22'\n\n      - name: Install build dependencies (Linux)\n        if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y nasm yasm pkg-config\n\n      - name: Install build dependencies (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          brew install nasm pkg-config\n\n      - name: Build FFmpeg 8.0.1 (minimal static)\n        run: |\n          curl -L https://ffmpeg.org/releases/ffmpeg-8.0.1.tar.xz | tar -xJ\n          cd ffmpeg-8.0.1\n          # Build minimal FFmpeg with only built-in codecs (no external libs)\n          # This ensures fully static binary with no runtime dependencies\n          ./configure \\\n            --enable-static \\\n            --disable-shared \\\n            --disable-programs \\\n            --disable-doc \\\n            --disable-network \\\n            --disable-encoders \\\n            --disable-muxers \\\n            --disable-outdevs \\\n            --disable-filters \\\n            --enable-filter=scale \\\n            --enable-filter=format \\\n            --disable-xlib \\\n            --disable-libxcb \\\n            --disable-sdl2 \\\n            --disable-lzma \\\n            --disable-zlib \\\n            --disable-bzlib \\\n            --disable-iconv \\\n            --disable-libxml2 \\\n            --disable-securetransport \\\n            --prefix=$HOME/ffmpeg\n          make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)\n          make install\n\n      - name: Get version from tag\n        id: version\n        run: echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n\n      - name: Build with static FFmpeg\n        env:\n          CGO_ENABLED: 1\n        run: |\n          export PKG_CONFIG_PATH=\"$HOME/ffmpeg/lib/pkgconfig\"\n          export CGO_LDFLAGS=\"$(pkg-config --static --libs libavdevice libavformat libavcodec libswscale libswresample libavutil)\"\n          export CGO_CFLAGS=\"$(pkg-config --cflags libavdevice libavformat libavcodec libswscale libswresample libavutil)\"\n          go build -ldflags \"-X main.version=${{ steps.version.outputs.VERSION }}\" -o dotmatrix ./cmd/dotmatrix\n\n      - name: Ad-hoc code sign (macOS)\n        if: runner.os == 'macOS'\n        run: codesign --sign - --force dotmatrix\n\n      - name: Create archive\n        run: |\n          tar -czvf ${{ matrix.artifact }}.tar.gz dotmatrix\n\n      - name: Upload release asset\n        uses: softprops/action-gh-release@v2\n        with:\n          files: ${{ matrix.artifact }}.tar.gz\n\n  update-homebrew:\n    needs: release\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Get version from tag\n        id: version\n        run: echo \"VERSION=${GITHUB_REF#refs/tags/v}\" >> $GITHUB_OUTPUT\n\n      - name: Download release assets\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          gh release download v${{ steps.version.outputs.VERSION }} \\\n            --repo kevin-cantwell/dotmatrix \\\n            --pattern '*.tar.gz' \\\n            --dir assets\n\n      - name: Compute SHA256 checksums\n        id: sha\n        run: |\n          echo \"DARWIN_ARM64=$(sha256sum assets/dotmatrix-darwin-arm64.tar.gz | awk '{print $1}')\" >> $GITHUB_OUTPUT\n          echo \"DARWIN_AMD64=$(sha256sum assets/dotmatrix-darwin-amd64.tar.gz | awk '{print $1}')\" >> $GITHUB_OUTPUT\n          echo \"LINUX_AMD64=$(sha256sum assets/dotmatrix-linux-amd64.tar.gz | awk '{print $1}')\" >> $GITHUB_OUTPUT\n\n      - name: Checkout homebrew-tap\n        uses: actions/checkout@v4\n        with:\n          repository: kevin-cantwell/homebrew-tap\n          ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}\n\n      - name: Update formula\n        env:\n          VERSION: ${{ steps.version.outputs.VERSION }}\n          SHA_DARWIN_ARM64: ${{ steps.sha.outputs.DARWIN_ARM64 }}\n          SHA_DARWIN_AMD64: ${{ steps.sha.outputs.DARWIN_AMD64 }}\n          SHA_LINUX_AMD64: ${{ steps.sha.outputs.LINUX_AMD64 }}\n        run: |\n          cat > Formula/dotmatrix.rb <<EOF\n          class Dotmatrix < Formula\n            desc \"Convert images and videos to Unicode braille art in the terminal\"\n            homepage \"https://github.com/kevin-cantwell/dotmatrix\"\n            version \"${VERSION}\"\n            license \"MIT\"\n\n            on_macos do\n              on_arm do\n                url \"https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-darwin-arm64.tar.gz\"\n                sha256 \"${SHA_DARWIN_ARM64}\"\n              end\n              on_intel do\n                url \"https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-darwin-amd64.tar.gz\"\n                sha256 \"${SHA_DARWIN_AMD64}\"\n              end\n            end\n\n            on_linux do\n              on_intel do\n                url \"https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-linux-amd64.tar.gz\"\n                sha256 \"${SHA_LINUX_AMD64}\"\n              end\n            end\n\n            def install\n              bin.install \"dotmatrix\"\n            end\n\n            test do\n              assert_match version.to_s, shell_output(\"#{bin}/dotmatrix --version\")\n            end\n          end\n          EOF\n\n      - name: Commit and push\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add Formula/dotmatrix.rb\n          git commit -m \"Update dotmatrix to ${{ steps.version.outputs.VERSION }}\"\n          git push\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  pull_request:\n    branches: [master, main]\n\njobs:\n  test:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: '1.22'\n\n      - name: Install FFmpeg 7.x development libraries\n        run: |\n          sudo add-apt-repository -y ppa:ubuntuhandbook1/ffmpeg7\n          sudo apt-get update\n          sudo apt-get install -y libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libavdevice-dev libavfilter-dev\n\n      - name: Build\n        run: CGO_ENABLED=1 go build ./...\n\n      - name: Test\n        run: CGO_ENABLED=1 go test ./... -v\n"
  },
  {
    "path": ".gitignore",
    "content": "_ignore/\n.DS_Store\ndotmatrix\n"
  },
  {
    "path": "LICENSE.md",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2016 Kevin Cantwell\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Dotmatrix\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/kevin-cantwell/dotmatrix.svg)](https://pkg.go.dev/github.com/kevin-cantwell/dotmatrix)\n\nConvert images to Unicode braille art for terminal display.\n\n<table>\n<tr>\n<td><strong>Input</strong></td>\n<td><strong>Output</strong></td>\n</tr>\n<tr>\n<td><img src=\"https://cloud.githubusercontent.com/assets/307864/14945003/a928affe-0fd3-11e6-9725-ae6824be4317.png\" alt=\"Input image\" width=\"300\"/></td>\n<td><img src=\"https://cloud.githubusercontent.com/assets/307864/14945005/c9b0d53a-0fd3-11e6-9b06-841eb637a2a0.png\" alt=\"Terminal output\" width=\"300\"/></td>\n</tr>\n</table>\n\n## Features\n\n- Encode JPEG, PNG, GIF, and BMP images as braille Unicode characters\n- Animated GIF support with proper frame timing and disposal methods\n- MP4 video playback with H.264 decoding\n- Embedded subtitle rendering for MP4 files (mov_text format)\n- Native webcam capture on macOS (AVFoundation)\n- Image adjustments: gamma, brightness, contrast, sharpening\n- Floyd-Steinberg dithering for grayscale preservation\n- Automatic scaling to fit terminal dimensions\n\n## Installation\n\n### Homebrew (macOS/Linux)\n\n```bash\nbrew install kevin-cantwell/tap/dotmatrix\n```\n\n### Pre-built Binaries\n\nDownload from [GitHub Releases](https://github.com/kevin-cantwell/dotmatrix/releases). Binaries are available for:\n- Linux (amd64)\n- macOS (arm64, amd64)\n\n**macOS users:** The binaries are not signed with an Apple Developer account. After downloading, remove the quarantine attribute:\n\n```bash\nxattr -d com.apple.quarantine dotmatrix\n```\n\n### Install with Go\n\n```bash\ngo install github.com/kevin-cantwell/dotmatrix/cmd/dotmatrix@latest\n```\n\n**macOS users:** The binary may be killed immediately due to an invalid code signature. If this happens, re-sign it:\n\n```bash\ncodesign --force --sign - $(go env GOPATH)/bin/dotmatrix\n```\n\n### Building from Source\n\nBuilding from source requires FFmpeg 8.x development libraries and CGO. Pre-built binaries have FFmpeg statically linked and require no runtime dependencies.\n\n**macOS:**\n```bash\nbrew install ffmpeg pkg-config\nCGO_ENABLED=1 go build -o dotmatrix ./cmd/dotmatrix\n```\n\n**Ubuntu/Debian:**\n```bash\n# FFmpeg 8.x may need to be built from source if not available in repos\nsudo apt-get install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libavdevice-dev\nCGO_ENABLED=1 go build -o dotmatrix ./cmd/dotmatrix\n```\n\n## Usage\n\n### Command Line\n\n```bash\n# From file\ndotmatrix image.png\n\n# From URL\ndotmatrix https://example.com/image.jpg\n\n# From stdin\ncurl -s https://example.com/image.jpg | dotmatrix\n\n# With options\ndotmatrix --invert --sharpen 50 image.png\n\n# Play MP4 video (subtitles are displayed if embedded)\ndotmatrix video.mp4\n\n# Play MP4 at specific framerate\ndotmatrix --fps 15 video.mp4\n\n# Capture from webcam (macOS only)\ndotmatrix --webcam\n\n# Webcam with options\ndotmatrix --webcam --invert --fps 15\n```\n\n### As a Library\n\n```go\npackage main\n\nimport (\n    \"image\"\n    \"os\"\n\n    \"github.com/kevin-cantwell/dotmatrix\"\n)\n\nfunc main() {\n    img, _, _ := image.Decode(os.Stdin)\n    dotmatrix.Print(os.Stdout, img)\n}\n```\n\n## Options\n\n| Flag | Description |\n|------|-------------|\n| `--invert`, `-i` | Invert colors (for dark backgrounds) |\n| `--gamma`, `-g` | Adjust gamma: negative darkens, positive lightens |\n| `--brightness`, `-b` | Adjust brightness (-100 to 100) |\n| `--contrast`, `-c` | Adjust contrast (-100 to 100) |\n| `--sharpen`, `-s` | Sharpen image |\n| `--mirror`, `-m` | Flip image horizontally |\n| `--mono` | Disable Floyd-Steinberg dithering |\n| `--webcam`, `-w` | Capture from webcam (macOS only) |\n| `--framerate`, `--fps` | Set playback framerate |\n| `--mimeType`, `--mime` | Override auto-detected MIME type |\n\n## Examples\n\n### Sharpened Image\n\n```bash\ndotmatrix --sharpen 100 face.jpg\n```\n\n```\n⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣜⢽⡺⣿⣿⣺⣿⣏⣿⣿⣿⣿⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣜⡞⣧⢅⢳⡙⣼⣻⡢⡺⡼⣻⢞⡯⣟⣽⢻⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⣯⣿⣿⣿⡿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡜⣖⢔⠕⢸⡳⡢⡱⠅⡞⣽⠱⢳⢫⡟⡞⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣱⣿⣿⣵⡿⣿⣟⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣷⢿⡽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n⣿⣿⣿⡿⡽⣿⢿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⢣⢫⠀⠣⠂⢘⠐⢁⢊⠠⠉⢎⢎⢣⢿⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⡼⡜⡾⡯⡫⢽⢽⣗⢟⣿⣿⣿⣿⣿⣿⣿⣟⡮⣟⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n⣿⣿⡿⡭⡺⣫⣟⡷⣟⢾⢽⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣎⠄⢊⢂⠠⠈⠄⠂⠀⢁⢑⠁⢜⡧⡗⢡⢮⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣜⢜⢌⢊⠈⣄⢜⢿⡫⣿⣿⣿⣿⣿⣿⣿⡿⡱⣻⢟⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n```\n\n### Animated GIF\n\nAnimated GIFs play directly in the terminal with proper timing:\n\n![Animated GIF example](https://cloud.githubusercontent.com/assets/307864/16272242/0dc3b6d8-386b-11e6-9ea3-e55ee936ae54.gif)\n\n> **Note:** Terminal refresh rates vary. The default macOS Terminal.app works well; iTerm2 may have slower refresh rates.\n\n### MP4 Subtitles\n\nMP4 files with embedded subtitles (mov_text/tx3g format) will display subtitles overlaid at the bottom of the video. Subtitles are:\n\n- Automatically extracted and timed to video playback\n- Centered and wrapped to fit the terminal width\n- Rendered over the braille output while preserving surrounding pixels\n\n## How It Works\n\nDotmatrix uses [Unicode Braille Patterns](https://en.wikipedia.org/wiki/Braille_Patterns) (U+2800 to U+28FF) to represent images. Each braille character encodes a 2x4 pixel grid, allowing 256 possible patterns per character.\n\n**Processing pipeline:**\n\n1. **Decode** - Parse input (JPEG, PNG, GIF, BMP, or MP4 video)\n2. **Filter** - Apply brightness, contrast, gamma, sharpening adjustments\n3. **Scale** - Resize to fit terminal dimensions (2 pixels per column, 4 pixels per row)\n4. **Dither** - Convert to monochrome using Floyd-Steinberg diffusion\n5. **Encode** - Map each 2x4 pixel block to a braille character\n6. **Render** - Output braille characters with newlines (for video, frames are rendered in sequence)\n\nThe Floyd-Steinberg dithering algorithm distributes quantization errors to neighboring pixels, preserving the appearance of grayscale gradients in the monochrome output.\n\n## License\n\nMIT\n"
  },
  {
    "path": "braille.go",
    "content": "package dotmatrix\n\nimport (\n\t\"image\"\n\t\"image/color\"\n\t\"io\"\n)\n\n// Braille epresents an 8 dot braille pattern in x,y coordinates space. Eg:\n//   +----------+\n//   |(0,0)(1,0)|\n//   |(0,1)(1,1)|\n//   |(0,2)(1,2)|\n//   |(0,3)(1,3)|\n//   +----------+\ntype Braille [2][4]int\n\n// Rune maps each point in braille to a dot identifier and\n// calculates the corresponding unicode symbol.\n//   +------+\n//   |(1)(4)|\n//   |(2)(5)|\n//   |(3)(6)|\n//   |(7)(8)|\n//   +------+\n// See https://en.wikipedia.org/wiki/Braille_Patterns#Identifying.2C_naming_and_ordering)\nfunc (b Braille) Rune() rune {\n\tlowEndian := [8]int{b[0][0], b[0][1], b[0][2], b[1][0], b[1][1], b[1][2], b[0][3], b[1][3]}\n\tvar v int\n\tfor i, x := range lowEndian {\n\t\tv += int(x) << uint(i)\n\t}\n\treturn rune(v) + '\\u2800'\n}\n\n// String returns a unicode braille character. One of:\n//  ⣿ ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⡈⡉⡊⡋⡌⡍⡎⡏⡐⡑⡒⡓⡔⡕⡖⡗⡘⡙⡚⡛⡜⡝⡞⡟⡠⡡⡢⡣⡤⡥⡦⡧⡨⡩⡪⡫⡬⡭⡮⡯⡰⡱⡲⡳⡴⡵⡶⡷⡸⡹⡺⡻⡼⡽⡾⡿⢀⢁⢂⢃⢄⢅⢆⢇⢈⢉⢊⢋⢌⢍⢎⢏⢐⢑⢒⢓⢔⢕⢖⢗⢘⢙⢚⢛⢜⢝⢞⢟⢠⢡⢢⢣⢤⢥⢦⢧⢨⢩⢪⢫⢬⢭⢮⢯⢰⢱⢲⢳⢴⢵⢶⢷⢸⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣈⣉⣊⣋⣌⣍⣎⣏⣐⣑⣒⣓⣔⣕⣖⣗⣘⣙⣚⣛⣜⣝⣞⣟⣠⣡⣢⣣⣤⣥⣦⣧⣨⣩⣪⣫⣬⣭⣮⣯⣰⣱⣲⣳⣴⣵⣶⣷⣸⣹⣺⣻⣼⣽⣾\nfunc (b Braille) String() string {\n\treturn string(b.Rune())\n}\n\ntype BrailleFlusher struct{}\n\nfunc (BrailleFlusher) Flush(w io.Writer, img image.Image) error {\n\t// An image's bounds do not necessarily start at (0, 0), so the two loops start\n\t// at bounds.Min.Y and bounds.Min.X.\n\t// Looping over Y first and X second is more likely to result in better memory\n\t// access patterns than X first and Y second.\n\tbounds := img.Bounds()\n\tfor py := bounds.Min.Y; py < bounds.Max.Y; py += 4 {\n\t\tfor px := bounds.Min.X; px < bounds.Max.X; px += 2 {\n\t\t\tvar b Braille\n\t\t\t// Draw left-right, top-bottom.\n\t\t\tfor y := 0; y < 4; y++ {\n\t\t\t\tfor x := 0; x < 2; x++ {\n\t\t\t\t\tif px+x >= bounds.Max.X || py+y >= bounds.Max.Y {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// Always bet on black!\n\t\t\t\t\tif img.At(px+x, py+y) == color.Black {\n\t\t\t\t\t\tb[x][y] = 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif _, err := w.Write([]byte(b.String())); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif _, err := w.Write([]byte{'\\n'}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "doc.go",
    "content": "/*\nPackage dotmatrix encodes images in a \"dot matrix\" pattern using braille unicode characters.\nImages are first converted to monochrome, then each 2x4 pixel block is coded to an\n8-dot braille character. In this fashion, an image's entire pixel set can be mapped, one-by-one,\nto either a \"filled\" or \"unfilled\" braille dot. The resulting braille symbols are arranged as\nlines of text to form a representation of the original image. The encoded image is\nlossy in the sense that color information is reduced to monochrome with diffusion, but otherwise\nthe resulting text-based image is pixel perfect. In other words, if a pure monochrome image\nis used as input, there will be no loss of information.\n\nhttps://en.wikipedia.org/wiki/Braille_Patterns⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡳⡧⡓⡝⣟⢿⢼⣿⠳⣿⣿⣿⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣼⣷⢿⣿⣿⢿⣻⣿⣺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣫⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣎⠷⡄⠣⠣⣺⡻⠨⡧⢛⣯⢟⢾⡵⣻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⠽⣫⣾⢿⢾⣟⣺⣻⣿⣿⣿⣿⣿⣿⣿⣾⣽⡝⣽⢻⡻⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⢟⣿⢿⣿⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡱⡱⡁⠈⡯⠘⢜⠘⢸⠈⡣⣳⢫⠹⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣕⡝⡻⣺⡾⢿⢮⡗⣟⣿⣿⣿⣿⣿⣿⣟⣗⢻⢝⣳⣏⣾⣿⣿⣿⣿⣽⣽⣟⣿⣿\n  ⣿⣿⣿⢑⣝⢯⡯⣾⢻⣻⣽⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣅⠕⠀⠡⠀⠁⠌⠐⠈⠄⢣⠊⣾⡏⡟⢻⣿⣿⣿⣿⣿⣿⣿⣿⣯⡆⢍⠏⢓⠁⠃⣻⣵⢻⣿⣿⣿⣿⣿⣿⢞⢪⢾⣿⢫⣾⣿⣿⣿⣿⣷⣿⣿⣿⣿\n  ⣿⣿⠣⢁⠪⡳⡫⣮⣱⢱⠹⣳⢻⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣨⠀⠀⠀⠁⠀⠀⠅⠅⢏⡻⡨⠪⢨⠻⣿⣿⣿⣿⣿⣿⣿⣿⣔⠈⢀⠔⠘⠈⡃⣙⣿⣿⣿⣿⣿⣿⣕⡘⢭⢎⡷⣟⣾⣾⣿⣿⡻⣵⢿⣾⣿\n  ⣿⢇⡊⡄⡏⢉⡫⣫⢝⢮⠺⣔⠭⡚⢐⠜⢻⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀⠡⠃⠐⠈⠀⠁⠄⠹⢿⣿⣿⣿⣿⣿⣿⣷⡁⢂⠁⠀⢀⠐⠅⠚⣿⣿⣿⣿⣿⣿⠂⠢⡳⣻⣺⣿⢿⣿⣿⣟⣳⢻⣵⣿\n  ⣿⡇⣫⠂⡀⠃⠀⠅⡝⣒⠳⠌⠕⠁⠂⠈⠀⠈⠈⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢘⣿⣿⣿⣿⣿⣿⣿⣮⠀⠀⠀⠀⠀⠀⢍⠹⣿⣿⣿⣿⣿⢅⢪⡽⡽⣹⣜⣽⣿⣿⡯⢺⢫⣻⣿\n  ⣿⣾⣵⣶⣷⣬⣅⠅⠌⠘⡐⣈⠁⡀⠄⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠥⢐⣽⣿⣿⣿⣿⡣⠈⡩⣓⣵⡻⢽⣿⣿⢗⣕⣗⢷⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣭⣥⡉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣷⡆⠀⠀⠀⠀⠀⠈⢽⣿⣿⣿⢿⡕⠈⠴⠵⢵⢶⣿⣿⣿⡗⣪⣼⢞⣷\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡄⠀⠀⠀⠀⠀⠀⠈⢹⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⢼⣿⣿⣿⣷⠕⠈⠠⢦⢵⣻⣿⣿⣿⣞⣝⢿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠨⣿⣿⣿⣿⠡⠀⡈⠀⠩⣿⣿⣿⣟⢇⠃⣗⣿⣟\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠈⢻⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠈⣿⣿⣿⣿⠀⠀⠀⠀⠑⣿⣿⣿⣿⡥⠠⣽⣿⣿\n  ⣿⣿⢯⢿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡀⠀⠀⠀⠀⠀⠀⠉⠻⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⣿⣿⣿⣷⠀⠀⠀⠀⢱⣿⣿⣿⣟⠎⣪⣿⣿⣪\n  ⣿⣿⡯⡻⡙⡋⠉⠪⠁⠀⠀⠀⠀⠀⠀⠉⠉⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⡿⠀⠀⠀⠀⢿⣿⣿⣿⡫⢢⣿⣿⣯⢷\n  ⣿⡿⣯⡪⡂⡄⢄⢁⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⢹⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⡇⠀⠀⠀⣐⣾⣿⡿⡧⢑⢶⣿⡟⣳⣿\n  ⠀⠈⢳⣳⣽⣿⣾⣶⣿⣯⡶⢴⣴⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠉⢻⣿⣿⣿⣿⣧⠀⠀⠀⠀⠸⣿⣿⣿⣿⠀⠀⠀⠀⢀⣿⣿⣿⠇⠀⠀⠀⣽⣿⡿⡏⡐⢸⣿⣟⢳⣷⣿\n  ⣤⣶⣽⣟⣿⣿⣿⣿⣿⢕⣽⢽⣺⢿⣿⣿⣿⣶⣦⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀⠙⠛⢿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣧⠀⠀⠀⠀⣿⣿⣿⣿⠀⠀⠀⠀⢰⣿⣿⣿⠁⠀⠀⢠⣿⣿⠽⠨⣮⣾⣿⣣⣿⣾⠟\n  ⣷⣿⣿⣿⣿⣿⣿⣟⢎⠧⡫⣻⢟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⡀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠘⢿⣿⣿⣿⡄⠀⠀⠀⢹⣿⣿⣿⠀⠀⠀⠀⣼⣿⣿⡏⠀⠀⠀⣼⣿⠇⠀⣼⣿⡿⣪⣿⣿⠋⢀\n  ⣿⣿⣿⣿⣿⣿⣿⠁⠁⠀⠀⠀⠀⠉⠉⠉⠛⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣦⡀⠀⠀⠈⢻⣿⣿⣷⡀⠀⠀⠈⣿⣿⣯⠀⠀⠀⢀⣿⣿⡿⠁⠀⠀⢰⣿⡟⠀⢰⣿⡟⢝⢸⡟⠁⣠⣾\n  ⣿⣿⠏⠑⢽⡻⢑⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⢿⣿⣿⣿⣿⣿⣷⣦⣤⡀⠀⠀⠀⠈⠙⣿⣿⣿⣷⣄⠀⠀⠀⠻⣿⣿⣧⠀⠀⠀⢿⣿⣷⠀⠀⠀⢸⣿⣿⠃⠀⠀⢀⣿⣿⠁⠀⣾⠉⢓⠠⡿⠅⣴⣿⣿\n  ⣿⣯⠀⢈⠕⡌⠆⠀⣠⣠⣤⣤⣤⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⢿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠙⢿⣿⣿⣦⡀⠀⠀⠹⣿⣿⠀⠀⠀⢹⣿⣿⠀⠀⠀⢸⣿⡿⠀⠀⠀⣼⣿⠃⠀⣸⠃⠀⠪⣾⡫⣲⣿⣿⣿\n  ⣿⠁⠀⠀⢕⢨⣺⣽⢝⡮⡷⡵⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠿⣿⣿⣿⣶⣤⠀⠀⠈⠙⣿⣿⣷⣄⠀⠀⠻⣿⣇⠀⠀⢸⣿⣿⠀⠀⠀⣾⣿⠃⠀⠀⣼⣿⠃⠀⣼⠏⠀⢠⣿⣾⣿⣿⣿⣿⣿\n  ⡿⠀⠀⢈⣦⣿⣿⡯⣽⣺⢝⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠉⠛⠻⢿⣿⣦⣀⠀⠈⠛⣿⣿⣦⡀⠀⠙⣿⡆⠀⠘⣿⣿⠀⠀⢐⣿⡟⠀⢀⣔⠟⠃⢀⡼⠋⠀⢀⣾⣿⣿⢿⣿⣿⠟⠁\n  ⣿⠠⢲⣻⣽⣿⣿⡝⠚⠊⠓⠛⠛⠛⠛⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⢿⢿⣿⣿⣿⣶⣶⣤⣤⣀⠀⠀⠀⠀⠉⠛⠻⢷⣤⡀⠈⠙⢿⣷⣦⡀⠘⢿⡄⠀⣹⣿⣀⣀⣿⡋⢀⣠⠾⠋⣀⡴⠋⠁⠀⢠⣾⣿⠋⠃⠻⠛⠁⠀⠀\n  ⣿⣴⣾⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠐⢐⣶⣶⣾⣾⢾⣶⣶⣶⣶⣶⣦⣭⣍⠛⢛⠛⠿⠿⣿⣷⣶⣦⣄⣀⠀⠀⠉⠛⠶⣦⢤⣽⣿⣷⣶⣾⣿⣿⣿⣿⣿⡿⢿⣿⣿⣿⡒⠚⠉⠁⢀⣤⣾⣿⣿⠓⠀⢰⠆⠀⠀⠀⠀\n  ⣿⣿⣿⣿⣿⢟⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⣛⣿⠛⠃⠈⠻⣛⠛⠛⣻⠋⠛⣿⣿⣶⣬⡠⠀⠀⠀⠀⠉⠉⠙⠛⠛⠒⠲⠒⢒⣫⣵⢿⣿⠋⠙⠿⣋⣋⣻⠛⠁⠁⠛⣻⣯⣶⣶⣶⣾⣿⣿⠿⠛⠁⠀⢀⡺⡧⠀⣠⣴⣾\n  ⣿⣿⣏⠁⠁⡃⠆⠀⠀⠀⢀⣠⣠⣤⣤⣶⣶⣿⣿⣿⣦⣄⣀⠈⠉⠉⠀⢀⣀⢻⣾⣿⣿⣿⣷⣦⣤⣤⣤⣤⣤⣤⣤⣤⣤⠖⠋⠉⣠⣟⠻⣱⣤⣤⣤⣅⣀⣤⣤⣶⣿⠿⠟⠛⠋⠉⠉⠉⠀⠀⠀⠀⢠⣞⣮⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣦⡀⠀⠁⢠⢔⢾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠋⠈⠀⠀⠈⠙⠙⠋⠙⠉⠉⠉⠉⠉⠁⣤⣶⠟⠋⣡⠔⣩⢺⢿⢻⠯⡙⠓⠶⣤⣤⣤⣤⣤⣤⣶⣶⣶⣶⣶⣶⣾⣿⢵⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣶⣷⣮⢇⢯⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠛⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⠛⠁⡴⠊⣡⠖⡅⣨⠏⡜⡄⠹⣶⣤⡀⠉⠉⠉⠋⠛⠛⠛⠛⠛⠛⠛⠋⠂⢸⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⡧⣫⢻⣿⣿⣿⠿⠛⠛⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣤⣤⣴⣶⣶⣶⣾⣿⡿⠛⠁⣠⡞⢀⡴⠁⡌⢠⡏⡎⢇⢻⡄⠉⠻⣿⣿⣶⣦⣤⣤⣤⣀⣀⡀⡀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⡪⠟⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣶⣿⣿⣿⣿⣿⣿⠿⠛⠛⠉⠁⠀⢠⣴⠛⢡⣾⠃⣼⠁⣼⡇⢧⠸⣤⠹⣦⡀⠀⠉⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⡪⣪⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣴⣾⣿⣿⣿⣿⠿⠛⠉⠉⠁⠀⠀⠀⠀⠀⣠⣶⡿⠃⣠⣿⠃⢠⡏⠀⣿⡇⢹⡇⠹⣧⠙⢿⣦⣄⠀⠀⠀⠈⠙⠛⠻⢿⣿⡿⡱⣽⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⣀⣠⣴⣶⣿⣿⣿⣿⣿⣿⡿⠛⠉⠁⠀⠀⠀⠀⠀⠀⢀⣠⣶⣿⣿⠏⠀⢰⣿⡏⠀⣼⠁⠀⣿⡇⠈⣿⠀⠹⣦⠈⠛⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠉⠀⣸⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⢀⣠⣶⣾⣿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⠟⠀⠀⢠⣾⡟⠀⢰⡟⠀⠀⣿⡇⠀⢻⣇⠀⠹⣷⡀⠈⠛⢿⣿⣿⣦⣄⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣶⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⡿⠛⠁⠀⠀⢠⣿⣿⠇⠀⢸⡇⠀⠐⣿⣷⠀⢸⣿⡆⠀⢹⣿⣄⠀⠈⠙⢿⣿⣿⣿⣶⣶⡴⣺⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣿⣿⣿⣽⠿⠋⠀⠀⠀⢀⣴⣿⣿⡿⠀⠀⣸⡇⠀⢸⣿⣿⠀⠀⣿⣷⠀⠀⢿⣿⣦⠀⠀⠀⠙⠻⣿⣿⣿⠕⣹⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠉⠀⠀⠀⠀⠀⠀⠀⣠⣴⣾⣿⣿⣿⡿⣫⣑⣦⣄⠀⠀⣠⣼⣿⣿⣟⣥⣴⣴⠟⠁⠀⢸⣿⣿⠀⠀⢹⣿⣦⠀⠈⢻⣿⣷⣄⠀⠀⠀⠈⠉⠁⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⡟⠋⠀⠀⠑⠁⢙⣶⣾⣿⣿⠟⠉⠉⢡⣿⣿⠀⠀⠀⢸⣿⣿⡄⠀⠀⣿⣿⣇⠀⠈⠻⣿⣿⣷⣄⣀⢀⠀⡀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⣰⣾⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⢀⣼⣿⣿⣿⠃⠀⠀⠀⢸⣿⣿⠀⠀⠀⠘⣿⣿⣷⡀⠀⢸⣿⣿⡄⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⠋⠀⠀⠀⠀⣾⣿⣿⠀⠀⠀⠀⣿⣿⣿⣧⠀⠀⢻⣿⣿⡄⠀⠀⠈⠛⠿⢿⠿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⣶⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⡟⠀⠀⠀⠀⢠⣿⣿⡿⠀⠀⠀⠀⣿⣿⣿⣿⡄⠀⠀⢻⣿⣷⡄⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣾⣿⣿⣿⣿⣿⡿⠉⠀⠀⢀⡀⡀⣀⣠⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⣾⣿⣿⡯⠀⠀⠀⠀⣿⣿⣿⣿⣧⠀⠀⠈⣿⣿⣿⣦⡀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠉⠙⠛⣻⣿⣿⣿⣿⣿⠛⠓⠒⠒⠒⢳⣿⣿⣿⡟⠒⠒⠓⠛⣿⣿⣿⣿⣿⡄⠀⠀⠘⣿⣿⣿⣿⠗⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠈⢻⣿⠏⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⠀⠀⠀⠀⢸⣿⣿⣿⣿⣷⠀⠀⠀⠘⣿⡿⠉⠀⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣽⣿⡣⠀⠈⡋⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣧⠀⠀⠀⣸⡅⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣹⣾⣿⠂⠀⢐⠨⠢⡀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⡿⠁⠣⠲⠶⡲⢖⣾⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣇⣠⣼⣿⣷⡀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢏⢏⡾⣟⣿⠠⠀⠄⢂⠑⠔⡄⠀⢠⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡦⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡫⠣⡱⡵⣻⣿⣯⢃⠀⢐⠠⠈⡊⢼⣶⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠈⢻⡿⠛⣿⣿⣿⣿⣿⣿⣿⣿⡥⡀⠀⠀⠑⠹⢿⣿⣿⣿⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⣿⣿⡿⡟⠝⡑⢌⢪⢪⢞⣯⡷⡯⡂⠄⠀⡂⠐⠨⡸⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⢀⠌⠀⠀⠘⢹⣿⣿⣿⣿⣿⣿⣿⣧⡂⠀⠀⠀⠁⠩⠻⣻⣿⣿⣿⣿⣿\n  ⣿⣿⣿⣿⣿⢟⠫⡊⠌⢌⢐⢅⢎⢮⣻⣺⢽⢝⠔⢀⠡⠀⠌⡐⢼⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠠⢐⠁⠌⠠⠀⠀⠀⠙⢻⣿⣿⣿⣿⣿⣿⣾⡢⡀⠠⠐⢀⠡⠈⠝⡻⣿⣿⣿\n  ⣿⡿⡟⡝⢌⠢⡑⠌⢌⠢⡂⡇⣗⢽⣺⡽⣽⢱⢁⠂⠄⡁⠂⠠⢱⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣶⡌⢈⠠⠈⠀⠂⠀⠀⠀⠀⠀⠛⢿⣿⣿⣿⣿⣿⣷⡪⡢⢈⠄⠂⢅⠑⢌⠢⠫⢿\n  ⡫⡪⠪⡨⠢⡑⠌⢜⢐⢑⢌⢮⡺⣝⣗⣟⢮⢣⠂⠌⡠⠀⠡⠈⠠⣻⣿⣿⣿⣿⣿⣿⣿⣿⢑⢲⡰⡤⡤⣄⢼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⡀⠀⢀⠈⡀⠂⠀⠈⢀⠀⠀⠈⠻⢿⣿⣿⣿⣿⣯⣞⢔⠌⢌⢐⢈⠢⢑⠍⡪\n  ⢸⠨⡊⡢⡑⢌⢊⠢⡑⡅⢇⠧⡯⣳⢯⣞⡝⡆⢅⠅⡂⠌⡐⢈⠀⢝⣿⣿⣿⣿⣿⣿⣿⣿⢜⠔⢕⢝⢜⢜⢜⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡐⠀⠄⢀⠈⠄⡁⠄⠠⠀⠀⠈⡈⢏⢿⣿⣿⣿⣯⣷⢍⢆⠢⡂⠌⡂⡑⢌\n  ⡘⢌⠢⡑⢌⢢⠡⡃⢕⢸⢨⡫⣞⡽⣳⢵⢝⢜⢐⢐⢀⢂⠐⠄⢂⢐⢽⣿⣿⣿⣿⣿⣿⣿⡇⡕⢕⢌⢂⠕⡨⢚⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡡⠐⢀⠀⢂⠀⡂⠐⡀⠁⠄⢐⢀⠣⡫⣻⣿⣿⣿⣟⣎⡇⡆⢅⠢⡈⡢\n  ⢸⠨⡊⡌⢆⢕⢑⠜⡌⡎⢮⢮⡳⣝⣗⣝⢮⢊⡂⡂⡢⠐⡈⢐⢀⠂⢽⣿⣿⣿⣿⣿⣿⣿⣯⡪⡪⡢⡑⢌⠔⡈⡺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡅⡂⠠⠀⠂⠀⠂⠄⠅⠨⠀⠄⢂⠪⡘⡎⡯⡿⣿⣷⣻⣜⢢⠡⢂⠢\n  ⠢⡃⡪⡨⡒⡬⡢⡣⡣⡹⣪⡳⡽⣕⢗⡎⡎⡖⡨⢂⠢⡁⡂⡂⡐⠨⡘⣿⣿⣿⣿⣿⣿⣿⣿⢮⢢⢑⠌⣂⠪⢐⠸⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡔⡅⠌⠠⠈⡐⠀⠌⠠⠁⡂⢐⠨⠐⡡⢱⢹⢽⣿⢿⣺⣜⢌⠢⡨\n  ⢕⢕⢜⡔⣕⢕⢕⢕⢕⢝⣜⢮⢳⢕⢗⡕⡇⡕⢌⠢⡑⡐⡐⠠⠐⠡⠨⣻⣿⣿⣿⣿⣿⣿⣿⣿⡰⢐⠌⡂⠅⠅⡘⠸⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡐⠄⠁⠄⠂⡈⠀⠅⡐⡐⠠⡁⡂⢅⢣⡫⣾⢿⣿⢾⡕⡕⡔\n  ⡷⡯⣷⢽⡮⡷⣝⢮⢮⡣⣗⢵⢹⢪⡣⡣⡣⡊⡢⡑⢔⢐⠌⠄⠡⠁⠅⢯⣿⣿⣿⣿⣿⣿⣿⣷⡇⡂⠐⠀⡁⠅⢀⠁⠕⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⣕⠅⢌⠐⡀⠅⢂⠢⠨⡢⢢⢱⢱⢱⡹⡮⡿⣽⣟⣮⢷⢵\n  ⢝⢝⢪⢫⢪⢫⢪⢫⢺⢺⢸⢪⢳⡱⡕⡕⡕⡌⡆⣊⠢⡂⡊⠨⢀⠁⡈⢎⣿⣿⣿⣿⣿⣿⣿⣿⣗⢄⠡⠀⠠⠈⢄⠐⡀⠌⢘⢝⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣾⡽⣦⡣⡐⡅⡢⡑⡕⡜⡎⡗⡝⡕⡝⡎⡏⢞⢜⢎⢏⢏\n  ⡑⢅⠣⡊⡢⢑⠅⠕⢅⠣⡃⢇⢇⢇⢏⢪⠪⡊⡎⢔⢑⠔⢌⠌⠠⠐⡀⠕⣝⣿⣿⣿⣿⣿⣿⣿⣗⠅⠀⠀⠀⠌⠠⠂⠄⠠⠀⠄⠩⡫⡺⡯⣻⣝⢯⣟⢿⢽⡺⡯⣗⢯⢷⡱⡜⢌⠪⡘⡌⡪⠪⡘⢌⠢⡊⣊⢢⢑⢅⢒\n*/\npackage dotmatrix\n"
  },
  {
    "path": "dotmatrix_suite_test.go",
    "content": "package dotmatrix_test\n\nimport (\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"testing\"\n)\n\nfunc TestDotmatrix(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\tRunSpecs(t, \"Dotmatrix Suite\")\n}\n"
  },
  {
    "path": "ffmpeg_check.go",
    "content": "package dotmatrix\n\n/*\n#cgo pkg-config: libavcodec libavformat libavutil\n\n#include <libavcodec/avcodec.h>\n#include <libavformat/avformat.h>\n#include <libavutil/avutil.h>\n\n// Require FFmpeg 8.x (libavcodec >= 61, libavformat >= 61, libavutil >= 59)\n// These version numbers correspond to FFmpeg n8.0\n#if LIBAVCODEC_VERSION_MAJOR < 61\n  #error \"FFmpeg 8.0 or later is required. Found libavcodec version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html\"\n#endif\n\n#if LIBAVFORMAT_VERSION_MAJOR < 61\n  #error \"FFmpeg 8.0 or later is required. Found libavformat version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html\"\n#endif\n\n#if LIBAVUTIL_VERSION_MAJOR < 59\n  #error \"FFmpeg 8.0 or later is required. Found libavutil version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html\"\n#endif\n*/\nimport \"C\"\n"
  },
  {
    "path": "gif.go",
    "content": "package dotmatrix\n\nimport (\n\t\"context\"\n\t\"image\"\n\t\"image/color\"\n\t\"image/gif\"\n\t\"io\"\n\t\"time\"\n)\n\ntype GIFPrinter struct {\n\tw io.Writer\n\tc Config\n}\n\nfunc NewGIFPrinter(w io.Writer, c *Config) *GIFPrinter {\n\treturn &GIFPrinter{\n\t\tw: w,\n\t\tc: mergeConfig(c),\n\t}\n}\n\n/*\n\tPrint animates a gif\n*/\nfunc (p *GIFPrinter) Print(ctx context.Context, giff *gif.GIF) error {\n\tif len(giff.Image) < 1 {\n\t\treturn nil\n\t}\n\n\t// Only used if we see background disposal methods\n\tbgPallette := []color.Color{color.Transparent}\n\tif giff.Config.ColorModel != nil {\n\t\tbgPallette = giff.Config.ColorModel.(color.Palette)\n\t}\n\n\t// The screen is what we flush to the writer on each iteration\n\tscreen := redraw(image.NewPaletted(giff.Image[0].Bounds(), bgPallette), p.c.Filter, p.c.Drawer)\n\trows := screen.Bounds().Dy() / 4\n\tif screen.Bounds().Dy()%4 != 0 {\n\t\trows++\n\t}\n\n\tfor c := 0; giff.LoopCount == 0 || c < giff.LoopCount; c++ {\n\t\tfor i := 0; i < len(giff.Image); i++ {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn ctx.Err()\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tdelay := time.After(time.Duration(giff.Delay[i]) * time.Second / 100)\n\n\t\t\tframe := redraw(giff.Image[i], p.c.Filter, p.c.Drawer)\n\n\t\t\tswitch giff.Disposal[i] {\n\t\t\tcase gif.DisposalPrevious: // Dispose previous essentially means draw then undo\n\t\t\t\ttemp := image.NewPaletted(screen.Bounds(), screen.Palette)\n\t\t\t\tcopy(temp.Pix, screen.Pix)\n\n\t\t\t\tp.drawOver(screen, frame)\n\t\t\t\tif err := flush(p.w, screen, p.c.Flusher); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t<-delay\n\n\t\t\t\tscreen = temp\n\t\t\tcase gif.DisposalBackground: // Dispose background replaces everything just drawn with the background canvas\n\t\t\t\tbackground := redraw(image.NewPaletted(frame.Bounds(), bgPallette), p.c.Filter, p.c.Drawer)\n\t\t\t\tp.drawExact(screen, background)\n\t\t\t\ttemp := image.NewPaletted(screen.Bounds(), screen.Palette)\n\t\t\t\tcopy(temp.Pix, screen.Pix)\n\n\t\t\t\tp.drawOver(screen, frame)\n\t\t\t\tif err := flush(p.w, screen, p.c.Flusher); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t<-delay\n\n\t\t\t\tscreen = temp\n\t\t\tdefault: // Dispose none or undefined means we just draw what we got over top\n\t\t\t\tp.drawOver(screen, frame)\n\t\t\t\tif err := flush(p.w, screen, p.c.Flusher); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t<-delay\n\t\t\t}\n\n\t\t\tp.c.Reset(p.w, rows)\n\t\t}\n\t}\n\treturn nil\n}\n\n// Draws any non-transparent pixels into target\nfunc (p *GIFPrinter) drawOver(target *image.Paletted, source image.Image) {\n\tbounds := source.Bounds()\n\tfor y := bounds.Min.Y; y < bounds.Max.Y; y++ {\n\t\tfor x := bounds.Min.X; x < bounds.Max.X; x++ {\n\t\t\tc := source.At(x, y)\n\t\t\tif c == color.Transparent {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttarget.Set(x, y, c)\n\t\t}\n\t}\n}\n\n// Draws pixels into target, including transparent ones.\nfunc (p *GIFPrinter) drawExact(target *image.Paletted, source image.Image) {\n\tbounds := source.Bounds()\n\tfor y := bounds.Min.Y; y < bounds.Max.Y; y++ {\n\t\tfor x := bounds.Min.X; x < bounds.Max.X; x++ {\n\t\t\ttarget.Set(x, y, source.At(x, y))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/kevin-cantwell/dotmatrix\n\ngo 1.22.0\n\ntoolchain go1.22.2\n\nrequire (\n\tgithub.com/asticode/go-astiav v0.40.0\n\tgithub.com/disintegration/imaging v1.6.2\n\tgithub.com/onsi/ginkgo v1.16.5\n\tgithub.com/onsi/gomega v1.36.2\n\tgithub.com/urfave/cli/v2 v2.27.5\n\tgolang.org/x/image v0.23.0\n\tgolang.org/x/term v0.28.0\n)\n\nrequire (\n\tgithub.com/asticode/go-astikit v0.42.0 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect\n\tgithub.com/fsnotify/fsnotify v1.4.9 // indirect\n\tgithub.com/google/go-cmp v0.6.0 // indirect\n\tgithub.com/nxadm/tail v1.4.8 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect\n\tgolang.org/x/net v0.33.0 // indirect\n\tgolang.org/x/sys v0.29.0 // indirect\n\tgolang.org/x/text v0.21.0 // indirect\n\tgopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/asticode/go-astiav v0.40.0 h1:7El8FyONtjPOmcmqR+zjjihG7ysum5RtTVIhIMrLFQM=\ngithub.com/asticode/go-astiav v0.40.0/go.mod h1:GI0pHw6K2/pl/o8upCtT49P/q4KCwhv/8nGLlCsZLdA=\ngithub.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w=\ngithub.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=\ngithub.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=\ngithub.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=\ngithub.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=\ngithub.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=\ngithub.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=\ngithub.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=\ngithub.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=\ngithub.com/onsi/ginkgo/v2 v2.22.1 h1:QW7tbJAUDyVDVOM5dFa7qaybo+CRfR7bemlQUN6Z8aM=\ngithub.com/onsi/ginkgo/v2 v2.22.1/go.mod h1:S6aTpoRsSq2cZOd+pssHAlKW/Q/jZt6cPrPlnj4a1xM=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=\ngithub.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=\ngithub.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=\ngithub.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=\ngolang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=\ngolang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=\ngolang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=\ngolang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=\ngolang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=\ngoogle.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "image.go",
    "content": "package dotmatrix\n\nimport (\n\t\"fmt\"\n\t\"image\"\n\t\"image/color\"\n\t\"image/draw\"\n\t\"io\"\n)\n\n// Flushes an image to the io.Writer. E.g. by using braille characters.\ntype Flusher interface {\n\tFlush(w io.Writer, img image.Image) error\n}\n\n// Filter may alter an image in any way, including resizing it.\n// It is applied prior to drawing the image in the dotmatrix palette.\ntype Filter interface {\n\tFilter(image.Image) image.Image\n}\n\ntype noop struct{}\n\nfunc (noop) Filter(img image.Image) image.Image {\n\treturn img\n}\n\ntype Config struct {\n\tFilter  Filter\n\tFlusher Flusher\n\tDrawer  draw.Drawer\n\t// Reset is invoked between animated frames of an image. It can be used to\n\t// apply custom cursor positioning.\n\tReset func(w io.Writer, rows int)\n}\n\nvar defaultConfig = Config{\n\tFilter:  noop{},\n\tFlusher: BrailleFlusher{},\n\tDrawer:  draw.FloydSteinberg,\n}\n\nfunc mergeConfig(c *Config) Config {\n\tif c == nil {\n\t\treturn defaultConfig\n\t}\n\tif c.Filter == nil {\n\t\tc.Filter = defaultConfig.Filter\n\t}\n\tif c.Drawer == nil {\n\t\tc.Drawer = defaultConfig.Drawer\n\t}\n\tif c.Flusher == nil {\n\t\tc.Flusher = defaultConfig.Flusher\n\t}\n\tif c.Reset == nil {\n\t\tc.Reset = func(w io.Writer, rows int) {\n\t\t\tfmt.Fprintf(w, \"\\033[999D\\033[%dA\", rows)\n\t\t}\n\t}\n\treturn *c\n}\n\nvar defaultPalette = []color.Color{color.Black, color.White, color.Transparent}\n\ntype Printer struct {\n\tw io.Writer\n\tc Config\n}\n\nfunc Print(w io.Writer, img image.Image) error {\n\treturn NewPrinter(w, &defaultConfig).Print(img)\n}\n\n// NewPrinter provides an Printer. If drawer is nil, draw.FloydSteinberg is used.\nfunc NewPrinter(w io.Writer, c *Config) *Printer {\n\treturn &Printer{\n\t\tw: w,\n\t\tc: mergeConfig(c),\n\t}\n}\n\n/*\nPrint prints the image as a series of braille and line feed characters and writes\nto w. Braille symbols are useful for representing monochrome images\nbecause any 2x4 pixel area can be represented by one of unicode's\n256 braille symbols. See: https://en.wikipedia.org/wiki/Braille_Patterns\n\nEach pixel of the image is converted to either black or white by redrawing the\nimage using the printer's drawer (Floyd Steinberg diffusion, by default) and a\n3-color palette of black, white, and transparent. Finally, each 2x4 pixel block\nis printed as a braille symbol.\n\nAs an example, this output was printed from a 134px by 108px image of Saturn:\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡿⡻⡫⡫⡣⣣⢣⢇⢧⢫⢻⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡟⡟⣝⣜⠼⠼⢚⢚⢚⠓⠷⣧⣇⠧⡳⡱⣻⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡟⣏⡧⠧⠓⠍⡂⡂⠅⠌⠄⠄⠄⡁⠢⡈⣷⡹⡸⣪⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⠿⢿⢿⢿⢟⢏⡧⠗⡙⡐⡐⣌⢬⣒⣖⣼⣼⣸⢸⢐⢁⠂⡐⢰⡏⣎⢮⣾⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣾⣶⣿⢿⢻⡱⢕⠋⢅⠢⠱⢼⣾⣾⣿⣿⣿⣿⣿⣿⣿⡇⡇⠢⢁⢂⡯⡪⣪⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⠏⢎⠪⠨⡐⠔⠁⠁⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⢱⠡⡁⣢⢏⢮⣾⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢍⢆⢃⢑⠤⠑⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⡿⡱⢑⢐⢼⢱⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⢫⡱⢊⢂⢢⠢⡃⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⢟⢑⢌⢦⢫⣪⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡻⡱⡑⢅⢢⣢⣳⢱⢑⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⡑⡑⡴⡹⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢝⠜⠨⡐⣴⣵⣿⣗⡧⡣⠢⢈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣜⢎⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡫⡱⠑⡁⣌⣮⣾⣿⣿⣿⣟⡮⡪⡪⡐⠠⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢏⠜⠌⠄⣕⣼⣿⣿⣿⣿⣿⣿⣯⡯⣎⢖⠌⠌⠄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢨⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⠡⣸⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡽⡮⡪⡪⠨⡂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⢐⢔⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢽⡱⡱⡑⡠⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⢐⢰⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣞⢜⠔⢄⠡⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⡿⡹⡰⠃⢈⠠⣢⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡮⣇⢏⢂⠢⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⢫⢒⡜⠐⠀⢢⣱⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣳⢕⢕⠌⠄⡀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⡿⡑⣅⠗⠀⡀⣥⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢙⠙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣯⢮⡪⣂⣢⣬⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⡟⡜⢌⡞⡀⣡⣾⣿⣿⣿⣿⣿⣿⣿⡿⠛⠉⢀⡠⠔⢜⣱⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⡿⡸⡘⢜⣧⣾⣿⣿⣿⣿⣿⣿⠿⢛⡡⠤⡒⢪⣑⣬⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⡇⡇⡣⣷⣿⣿⣿⣿⣿⠿⡛⡣⡋⣕⣬⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣮⣺⣿⣿⣟⣻⣩⣢⣵⣾⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\n\t⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿\n*/\nfunc (p *Printer) Print(img image.Image) error {\n\timg = redraw(img, p.c.Filter, p.c.Drawer)\n\treturn flush(p.w, img, p.c.Flusher)\n}\n\nfunc redraw(img image.Image, filter Filter, drawer draw.Drawer) *image.Paletted {\n\torigBounds := img.Bounds()\n\n\timg = filter.Filter(img)\n\n\tnewBounds := img.Bounds()\n\n\tscaleX := float64(newBounds.Dx()) / float64(origBounds.Dx())\n\tscaleY := float64(newBounds.Dy()) / float64(origBounds.Dy())\n\n\t// The offset is important because not all images have bounds starting at (0, 0), and\n\t// the filter may accidentally zero the min bounding point.\n\toffset := image.Pt(int(float64(origBounds.Min.X)*scaleX), int(float64(origBounds.Min.Y)*scaleY))\n\n\t// Create a new paletted image using a monochrome+transparent color palette.\n\tpaletted := image.NewPaletted(img.Bounds(), defaultPalette)\n\tpaletted.Rect = paletted.Bounds().Add(offset)\n\tdrawer.Draw(paletted, paletted.Bounds(), img, img.Bounds().Min)\n\treturn paletted\n}\n\nfunc flush(w io.Writer, img image.Image, flusher Flusher) error {\n\treturn flusher.Flush(w, img)\n\n}\n"
  },
  {
    "path": "image_test.go",
    "content": "package dotmatrix\n\n// import (\n// \t. \"github.com/onsi/ginkgo\"\n// \t. \"github.com/onsi/gomega\"\n// )\n\n// // ⡪⣛\n// //\n// var testCanvas = canvas{\n// \t{ // mx1\n// \t\tbraille{ // my1\n// \t\t\t{white, black, white, black}, // x1,y1-4\n// \t\t\t{black, white, black, white}, // x2,y1-4\n// \t\t},\n// \t\tbraille{ // my1\n// \t\t\t{black, black, black, black},                         // x1,y1-4\n// \t\t\t{transparent, transparent, transparent, transparent}, // x2,y1-4\n// \t\t},\n// \t},\n// \t{ // mx2\n// \t\tbraille{ // my2\n// \t\t\t{black, black, white, black}, // x1,y1-4\n// \t\t\t{black, black, white, black}, // x2,y1-4\n// \t\t},\n// \t\tbraille{ // my2\n// \t\t\t{transparent, transparent, transparent, transparent}, // x1,y1-4\n// \t\t\t{black, black, black, black},                         // x2,y1-4\n// \t\t},\n// \t},\n// }\n\n// var _ = Describe(\"canvas\", func() {\n// \tDescribe(\"#At\", func() {\n// \t\tIt(\"Should return the color at the given coordinate.\", func() {\n\n// \t\t\t// copy(c, testCanvas)\n// \t\t\tExpect(\"\\n\" + testCanvas.String()).To(Equal(\"\\n⡪⣛\\n\"))\n// \t\t})\n// \t})\n// })\n"
  },
  {
    "path": "mise.toml",
    "content": "[tools]\ngo = \"1.22\"\n"
  },
  {
    "path": "mp4.go",
    "content": "package dotmatrix\n\nimport (\n\t\"context\"\n\t\"encoding/binary\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/asticode/go-astiav\"\n)\n\n// subtitle holds a decoded subtitle with timing information.\ntype subtitle struct {\n\tstartPTS int64\n\tendPTS   int64\n\ttext     string\n}\n\n// MP4Printer prints MP4 video frames as braille characters.\ntype MP4Printer struct {\n\tw io.Writer\n\tc Config\n}\n\n// NewMP4Printer creates a new MP4Printer.\nfunc NewMP4Printer(w io.Writer, c *Config) *MP4Printer {\n\treturn &MP4Printer{\n\t\tw: w,\n\t\tc: mergeConfig(c),\n\t}\n}\n\n// Print plays an MP4 video from a file path. If fps is less than zero, it will\n// use the video's native framerate. Otherwise, fps dictates how many frames per\n// second are printed.\nfunc (p *MP4Printer) Print(ctx context.Context, inputPath string, fps int) error {\n\t// Allocate packet\n\tpkt := astiav.AllocPacket()\n\tif pkt == nil {\n\t\treturn fmt.Errorf(\"mp4: failed to allocate packet\")\n\t}\n\tdefer pkt.Free()\n\n\t// Allocate frame\n\tframe := astiav.AllocFrame()\n\tif frame == nil {\n\t\treturn fmt.Errorf(\"mp4: failed to allocate frame\")\n\t}\n\tdefer frame.Free()\n\n\t// Allocate format context\n\tformatCtx := astiav.AllocFormatContext()\n\tif formatCtx == nil {\n\t\treturn fmt.Errorf(\"mp4: failed to allocate format context\")\n\t}\n\tdefer formatCtx.Free()\n\n\t// Open input\n\tif err := formatCtx.OpenInput(inputPath, nil, nil); err != nil {\n\t\treturn fmt.Errorf(\"mp4: opening input failed: %w\", err)\n\t}\n\tdefer formatCtx.CloseInput()\n\n\t// Find stream info\n\tif err := formatCtx.FindStreamInfo(nil); err != nil {\n\t\treturn fmt.Errorf(\"mp4: finding stream info failed: %w\", err)\n\t}\n\n\t// Find video and subtitle streams\n\tvar videoStream *astiav.Stream\n\tvar videoStreamIdx int\n\tvar subtitleStream *astiav.Stream\n\tvar subtitleStreamIdx int\n\tfor _, s := range formatCtx.Streams() {\n\t\tswitch s.CodecParameters().MediaType() {\n\t\tcase astiav.MediaTypeVideo:\n\t\t\tif videoStream == nil {\n\t\t\t\tvideoStream = s\n\t\t\t\tvideoStreamIdx = s.Index()\n\t\t\t}\n\t\tcase astiav.MediaTypeSubtitle:\n\t\t\tif subtitleStream == nil {\n\t\t\t\tsubtitleStream = s\n\t\t\t\tsubtitleStreamIdx = s.Index()\n\t\t\t}\n\t\t}\n\t}\n\tif videoStream == nil {\n\t\treturn fmt.Errorf(\"mp4: no video stream found\")\n\t}\n\n\t// Find decoder\n\tcodec := astiav.FindDecoder(videoStream.CodecParameters().CodecID())\n\tif codec == nil {\n\t\treturn fmt.Errorf(\"mp4: decoder not found for codec %s\", videoStream.CodecParameters().CodecID())\n\t}\n\n\t// Allocate codec context\n\tcodecCtx := astiav.AllocCodecContext(codec)\n\tif codecCtx == nil {\n\t\treturn fmt.Errorf(\"mp4: failed to allocate codec context\")\n\t}\n\tdefer codecCtx.Free()\n\n\t// Copy codec parameters\n\tif err := videoStream.CodecParameters().ToCodecContext(codecCtx); err != nil {\n\t\treturn fmt.Errorf(\"mp4: copying codec parameters failed: %w\", err)\n\t}\n\n\t// Open codec\n\tif err := codecCtx.Open(codec, nil); err != nil {\n\t\treturn fmt.Errorf(\"mp4: opening codec failed: %w\", err)\n\t}\n\n\t// Set up subtitle storage if subtitle stream exists\n\tvar subtitles []subtitle\n\tvar subtitleTimeBase astiav.Rational\n\tif subtitleStream != nil {\n\t\tsubtitleTimeBase = subtitleStream.TimeBase()\n\t}\n\n\t// Create software scale context for converting to RGBA\n\tvar swsCtx *astiav.SoftwareScaleContext\n\tvar dstFrame *astiav.Frame\n\n\t// Get stream time base for timing calculations\n\ttimeBase := videoStream.TimeBase()\n\n\t// Calculate frame duration for fixed fps mode\n\tvar frameDuration time.Duration\n\tif fps > 0 {\n\t\tframeDuration = time.Second / time.Duration(fps)\n\t}\n\n\tvar rows int\n\tvar playbackStart time.Time  // Wall clock time when playback started\n\tvar firstPTS int64           // PTS of the first frame\n\tvar frameCount int64         // Frame counter for fixed fps mode\n\tvar initialized bool\n\n\t// Read packets\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\n\t\t// Read frame\n\t\tif err := formatCtx.ReadFrame(pkt); err != nil {\n\t\t\tif errors.Is(err, astiav.ErrEof) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"mp4: reading frame failed: %w\", err)\n\t\t}\n\n\t\t// Handle subtitle packets\n\t\tif subtitleStream != nil && pkt.StreamIndex() == subtitleStreamIdx {\n\t\t\t// Parse mov_text subtitle format (2-byte length prefix + UTF-8 text)\n\t\t\tdata := pkt.Data()\n\t\t\tif len(data) >= 2 {\n\t\t\t\ttextLen := int(binary.BigEndian.Uint16(data[:2]))\n\t\t\t\tif textLen > 0 && len(data) >= 2+textLen {\n\t\t\t\t\ttext := strings.TrimSpace(string(data[2 : 2+textLen]))\n\t\t\t\t\tif text != \"\" {\n\t\t\t\t\t\t// Convert subtitle PTS to video stream time base\n\t\t\t\t\t\tpktPTS := pkt.Pts()\n\t\t\t\t\t\tstartPTS := pktPTS * int64(subtitleTimeBase.Num()) * int64(timeBase.Den()) / (int64(subtitleTimeBase.Den()) * int64(timeBase.Num()))\n\t\t\t\t\t\t// Duration from packet, convert to video time base\n\t\t\t\t\t\tpktDuration := pkt.Duration()\n\t\t\t\t\t\tdurationPTS := pktDuration * int64(subtitleTimeBase.Num()) * int64(timeBase.Den()) / (int64(subtitleTimeBase.Den()) * int64(timeBase.Num()))\n\t\t\t\t\t\tendPTS := startPTS + durationPTS\n\n\t\t\t\t\t\tsubtitles = append(subtitles, subtitle{\n\t\t\t\t\t\t\tstartPTS: startPTS,\n\t\t\t\t\t\t\tendPTS:   endPTS,\n\t\t\t\t\t\t\ttext:     text,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tpkt.Unref()\n\t\t\tcontinue\n\t\t}\n\n\t\t// Skip non-video packets\n\t\tif pkt.StreamIndex() != videoStreamIdx {\n\t\t\tpkt.Unref()\n\t\t\tcontinue\n\t\t}\n\n\t\t// Send packet to decoder\n\t\tif err := codecCtx.SendPacket(pkt); err != nil {\n\t\t\tpkt.Unref()\n\t\t\tcontinue\n\t\t}\n\t\tpkt.Unref()\n\n\t\t// Receive frames from decoder\n\t\tfor {\n\t\t\tif err := codecCtx.ReceiveFrame(frame); err != nil {\n\t\t\t\tif errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\treturn fmt.Errorf(\"mp4: receiving frame failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Initialize scaler on first frame\n\t\t\tif swsCtx == nil {\n\t\t\t\tvar err error\n\t\t\t\tswsCtx, err = astiav.CreateSoftwareScaleContext(\n\t\t\t\t\tframe.Width(),\n\t\t\t\t\tframe.Height(),\n\t\t\t\t\tframe.PixelFormat(),\n\t\t\t\t\tframe.Width(),\n\t\t\t\t\tframe.Height(),\n\t\t\t\t\tastiav.PixelFormatRgba,\n\t\t\t\t\tastiav.NewSoftwareScaleContextFlags(astiav.SoftwareScaleContextFlagBilinear),\n\t\t\t\t)\n\t\t\t\tif err != nil {\n\t\t\t\t\tframe.Unref()\n\t\t\t\t\treturn fmt.Errorf(\"mp4: creating software scale context failed: %w\", err)\n\t\t\t\t}\n\t\t\t\tdefer swsCtx.Free()\n\n\t\t\t\tdstFrame = astiav.AllocFrame()\n\t\t\t\tif dstFrame == nil {\n\t\t\t\t\tframe.Unref()\n\t\t\t\t\treturn fmt.Errorf(\"mp4: failed to allocate destination frame\")\n\t\t\t\t}\n\t\t\t\tdefer dstFrame.Free()\n\t\t\t}\n\n\t\t\t// Scale frame to RGBA\n\t\t\tif err := swsCtx.ScaleFrame(frame, dstFrame); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"mp4: scaling frame failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Convert to Go image\n\t\t\timg, err := dstFrame.Data().GuessImageFormat()\n\t\t\tif err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"mp4: guessing image format failed: %w\", err)\n\t\t\t}\n\t\t\tif err := dstFrame.Data().ToImage(img); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"mp4: converting frame to image failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Initialize timing on first frame\n\t\t\tif !initialized {\n\t\t\t\tplaybackStart = time.Now()\n\t\t\t\tfirstPTS = frame.Pts()\n\t\t\t\tinitialized = true\n\t\t\t}\n\n\t\t\t// Calculate target display time and wait if needed\n\t\t\tvar targetTime time.Time\n\t\t\tif fps > 0 {\n\t\t\t\t// Fixed framerate: target time based on frame count\n\t\t\t\ttargetTime = playbackStart.Add(time.Duration(frameCount) * frameDuration)\n\t\t\t} else if fps < 0 {\n\t\t\t\t// Native timing: target time based on PTS\n\t\t\t\tptsDiff := frame.Pts() - firstPTS\n\t\t\t\tvideoTime := time.Duration(float64(ptsDiff) * float64(timeBase.Num()) / float64(timeBase.Den()) * float64(time.Second))\n\t\t\t\ttargetTime = playbackStart.Add(videoTime)\n\t\t\t}\n\n\t\t\t// Sleep until target time (if we're ahead of schedule)\n\t\t\tif sleepDuration := time.Until(targetTime); sleepDuration > 0 {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tframe.Unref()\n\t\t\t\t\treturn ctx.Err()\n\t\t\t\tcase <-time.After(sleepDuration):\n\t\t\t\t}\n\t\t\t}\n\t\t\tframeCount++\n\n\t\t\t// Apply filters and convert to paletted\n\t\t\tfilteredImg := redraw(img, p.c.Filter, p.c.Drawer)\n\n\t\t\t// Flush to output\n\t\t\tif err := flush(p.w, filteredImg, p.c.Flusher); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Calculate rows for cursor reset\n\t\t\tif rows == 0 {\n\t\t\t\trows = filteredImg.Bounds().Dy() / 4\n\t\t\t\tif filteredImg.Bounds().Dy()%4 != 0 {\n\t\t\t\t\trows++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Overlay subtitle on the bottom rows of the braille output\n\t\t\timageWidthInChars := filteredImg.Bounds().Dx() / 2\n\t\t\tcurrentPTS := frame.Pts()\n\t\t\tactiveSubtitle := findActiveSubtitle(subtitles, currentPTS)\n\t\t\tif activeSubtitle != \"\" {\n\t\t\t\t// Wrap and limit subtitle lines (max 3 lines, leave 1 line of braille visible at top minimum)\n\t\t\t\tmaxLines := 3\n\t\t\t\tif rows-1 < maxLines {\n\t\t\t\t\tmaxLines = rows - 1\n\t\t\t\t}\n\t\t\t\tif maxLines < 1 {\n\t\t\t\t\tmaxLines = 1\n\t\t\t\t}\n\t\t\t\tlines := wrapText(activeSubtitle, imageWidthInChars, maxLines)\n\t\t\t\tnumLines := len(lines)\n\n\t\t\t\t// Move cursor up to position subtitles at the bottom of the frame\n\t\t\t\t// We go up (numLines) lines from the current position (which is after the last row)\n\t\t\t\tfmt.Fprintf(p.w, \"\\033[%dA\", numLines)\n\n\t\t\t\t// Write each line centered (preserving braille on left and right)\n\t\t\t\tfor i, line := range lines {\n\t\t\t\t\t// Calculate column position for centered text (1-indexed for ANSI)\n\t\t\t\t\tlineLen := len([]rune(line))\n\t\t\t\t\tstartCol := (imageWidthInChars - lineLen) / 2\n\t\t\t\t\tif startCol < 1 {\n\t\t\t\t\t\tstartCol = 1\n\t\t\t\t\t}\n\t\t\t\t\t// Move cursor to the start column and write text (preserves braille on sides)\n\t\t\t\t\tfmt.Fprintf(p.w, \"\\033[%dG%s\", startCol, line)\n\t\t\t\t\tif i < numLines-1 {\n\t\t\t\t\t\tfmt.Fprint(p.w, \"\\n\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Move cursor back down to the original position\n\t\t\t\t// We're now on the last subtitle line (which is the last row of the frame)\n\t\t\t\t// So we only need to move down 1 line to get back to where we started\n\t\t\t\tfmt.Fprint(p.w, \"\\033[1B\\r\")\n\t\t\t}\n\n\t\t\tp.c.Reset(p.w, rows)\n\t\t\tframe.Unref()\n\t\t}\n\t}\n}\n\n// findActiveSubtitle returns the subtitle text that should be displayed at the given PTS.\nfunc findActiveSubtitle(subtitles []subtitle, pts int64) string {\n\tfor _, s := range subtitles {\n\t\tif pts >= s.startPTS && pts < s.endPTS {\n\t\t\treturn s.text\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// wrapText wraps text to fit within width, limiting to maxLines.\n// If text exceeds maxLines, the last line is truncated with ellipsis.\nfunc wrapText(text string, width, maxLines int) []string {\n\tif width <= 0 || maxLines <= 0 {\n\t\treturn nil\n\t}\n\n\ttext = strings.TrimSpace(text)\n\tif text == \"\" {\n\t\treturn nil\n\t}\n\n\twords := strings.Fields(text)\n\tif len(words) == 0 {\n\t\treturn nil\n\t}\n\n\tvar lines []string\n\tvar currentLine string\n\n\tfor _, word := range words {\n\t\twordRunes := []rune(word)\n\n\t\t// If single word is longer than width, we need to handle it specially\n\t\tif len(wordRunes) > width {\n\t\t\t// Flush current line if not empty\n\t\t\tif currentLine != \"\" {\n\t\t\t\tlines = append(lines, currentLine)\n\t\t\t\tcurrentLine = \"\"\n\t\t\t}\n\n\t\t\t// Break the long word across lines\n\t\t\tfor len(wordRunes) > 0 {\n\t\t\t\tif len(lines) >= maxLines {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ttake := width\n\t\t\t\tif take > len(wordRunes) {\n\t\t\t\t\ttake = len(wordRunes)\n\t\t\t\t}\n\t\t\t\tlines = append(lines, string(wordRunes[:take]))\n\t\t\t\twordRunes = wordRunes[take:]\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check if word fits on current line\n\t\tif currentLine == \"\" {\n\t\t\tcurrentLine = word\n\t\t} else if len([]rune(currentLine))+1+len(wordRunes) <= width {\n\t\t\tcurrentLine += \" \" + word\n\t\t} else {\n\t\t\t// Word doesn't fit, start new line\n\t\t\tlines = append(lines, currentLine)\n\t\t\tcurrentLine = word\n\t\t}\n\t}\n\n\t// Don't forget the last line\n\tif currentLine != \"\" {\n\t\tlines = append(lines, currentLine)\n\t}\n\n\t// Limit to maxLines and add ellipsis if truncated\n\tif len(lines) > maxLines {\n\t\tlines = lines[:maxLines]\n\t\t// Add ellipsis to last line\n\t\tlastLine := []rune(lines[maxLines-1])\n\t\tif len(lastLine)+3 <= width {\n\t\t\tlines[maxLines-1] = string(lastLine) + \"...\"\n\t\t} else if len(lastLine) > 3 {\n\t\t\tlines[maxLines-1] = string(lastLine[:len(lastLine)-3]) + \"...\"\n\t\t}\n\t}\n\n\treturn lines\n}\n"
  },
  {
    "path": "webcam.go",
    "content": "package dotmatrix\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/asticode/go-astiav\"\n)\n\n// WebcamPrinter prints webcam frames as braille characters.\ntype WebcamPrinter struct {\n\tw io.Writer\n\tc Config\n}\n\n// NewWebcamPrinter creates a new WebcamPrinter.\nfunc NewWebcamPrinter(w io.Writer, c *Config) *WebcamPrinter {\n\treturn &WebcamPrinter{\n\t\tw: w,\n\t\tc: mergeConfig(c),\n\t}\n}\n\n// Print captures frames from the webcam and renders them as braille.\n// If fps is less than zero, frames are rendered as fast as they arrive.\n// Otherwise, fps dictates how many frames per second are printed.\nfunc (p *WebcamPrinter) Print(ctx context.Context, fps int) error {\n\t// Register all devices to enable AVFoundation\n\tastiav.RegisterAllDevices()\n\n\t// Find AVFoundation input format (macOS)\n\tinputFormat := astiav.FindInputFormat(\"avfoundation\")\n\tif inputFormat == nil {\n\t\treturn fmt.Errorf(\"webcam: avfoundation input format not found (macOS only)\")\n\t}\n\n\t// Set up options\n\toptions := astiav.NewDictionary()\n\tdefer options.Free()\n\n\t// Set framerate - default to 30 if not specified (AVFoundation requires exact fps)\n\tif fps <= 0 {\n\t\tfps = 30\n\t}\n\toptions.Set(\"framerate\", fmt.Sprintf(\"%d\", fps), 0)\n\n\t// Set pixel format to one supported by most cameras\n\toptions.Set(\"pixel_format\", \"uyvy422\", 0)\n\n\t// Increase probesize to give AVFoundation time to initialize\n\toptions.Set(\"probesize\", \"32000000\", 0)\n\n\t// Allocate format context\n\tformatCtx := astiav.AllocFormatContext()\n\tif formatCtx == nil {\n\t\treturn fmt.Errorf(\"webcam: failed to allocate format context\")\n\t}\n\tdefer formatCtx.Free()\n\n\t// Open webcam device (device \"0\" is the first video device)\n\tif err := formatCtx.OpenInput(\"0\", inputFormat, options); err != nil {\n\t\t// Provide helpful error messages for common issues\n\t\terrStr := err.Error()\n\t\tif errStr == \"Operation not permitted\" || errStr == \"Permission denied\" {\n\t\t\treturn fmt.Errorf(\"webcam: permission denied - check System Preferences > Privacy & Security > Camera\")\n\t\t}\n\t\tif errStr == \"Device or resource busy\" {\n\t\t\treturn fmt.Errorf(\"webcam: camera is in use by another application\")\n\t\t}\n\t\treturn fmt.Errorf(\"webcam: failed to open camera: %w\", err)\n\t}\n\tdefer formatCtx.CloseInput()\n\n\t// Find stream info\n\tif err := formatCtx.FindStreamInfo(nil); err != nil {\n\t\treturn fmt.Errorf(\"webcam: finding stream info failed: %w\", err)\n\t}\n\n\t// Find video stream\n\tvar videoStream *astiav.Stream\n\tvar videoStreamIdx int\n\tfor _, s := range formatCtx.Streams() {\n\t\tif s.CodecParameters().MediaType() == astiav.MediaTypeVideo {\n\t\t\tvideoStream = s\n\t\t\tvideoStreamIdx = s.Index()\n\t\t\tbreak\n\t\t}\n\t}\n\tif videoStream == nil {\n\t\treturn fmt.Errorf(\"webcam: no video stream found\")\n\t}\n\n\t// Find decoder\n\tcodec := astiav.FindDecoder(videoStream.CodecParameters().CodecID())\n\tif codec == nil {\n\t\treturn fmt.Errorf(\"webcam: decoder not found for codec %s\", videoStream.CodecParameters().CodecID())\n\t}\n\n\t// Allocate codec context\n\tcodecCtx := astiav.AllocCodecContext(codec)\n\tif codecCtx == nil {\n\t\treturn fmt.Errorf(\"webcam: failed to allocate codec context\")\n\t}\n\tdefer codecCtx.Free()\n\n\t// Copy codec parameters\n\tif err := videoStream.CodecParameters().ToCodecContext(codecCtx); err != nil {\n\t\treturn fmt.Errorf(\"webcam: copying codec parameters failed: %w\", err)\n\t}\n\n\t// Open codec\n\tif err := codecCtx.Open(codec, nil); err != nil {\n\t\treturn fmt.Errorf(\"webcam: opening codec failed: %w\", err)\n\t}\n\n\t// Allocate packet\n\tpkt := astiav.AllocPacket()\n\tif pkt == nil {\n\t\treturn fmt.Errorf(\"webcam: failed to allocate packet\")\n\t}\n\tdefer pkt.Free()\n\n\t// Allocate frame\n\tframe := astiav.AllocFrame()\n\tif frame == nil {\n\t\treturn fmt.Errorf(\"webcam: failed to allocate frame\")\n\t}\n\tdefer frame.Free()\n\n\t// Create software scale context for converting to RGBA\n\tvar swsCtx *astiav.SoftwareScaleContext\n\tvar dstFrame *astiav.Frame\n\n\t// Calculate frame duration for fps limiting\n\tvar frameDuration time.Duration\n\tif fps > 0 {\n\t\tframeDuration = time.Second / time.Duration(fps)\n\t}\n\n\tvar rows int\n\tvar lastFrameTime time.Time\n\n\t// Read frames\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tdefault:\n\t\t}\n\n\t\t// Read frame\n\t\tif err := formatCtx.ReadFrame(pkt); err != nil {\n\t\t\tif errors.Is(err, astiav.ErrEof) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\t// EAGAIN means no frame available yet, just retry\n\t\t\tif errors.Is(err, astiav.ErrEagain) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Also check error string for \"Resource temporarily unavailable\" (EAGAIN on some systems)\n\t\t\tif strings.Contains(err.Error(), \"Resource temporarily unavailable\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"webcam: reading frame failed: %w\", err)\n\t\t}\n\n\t\t// Skip non-video packets\n\t\tif pkt.StreamIndex() != videoStreamIdx {\n\t\t\tpkt.Unref()\n\t\t\tcontinue\n\t\t}\n\n\t\t// Send packet to decoder\n\t\tif err := codecCtx.SendPacket(pkt); err != nil {\n\t\t\tpkt.Unref()\n\t\t\tcontinue\n\t\t}\n\t\tpkt.Unref()\n\n\t\t// Receive frames from decoder\n\t\tfor {\n\t\t\tif err := codecCtx.ReceiveFrame(frame); err != nil {\n\t\t\t\tif errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\treturn fmt.Errorf(\"webcam: receiving frame failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Initialize scaler on first frame\n\t\t\tif swsCtx == nil {\n\t\t\t\tvar err error\n\t\t\t\tswsCtx, err = astiav.CreateSoftwareScaleContext(\n\t\t\t\t\tframe.Width(),\n\t\t\t\t\tframe.Height(),\n\t\t\t\t\tframe.PixelFormat(),\n\t\t\t\t\tframe.Width(),\n\t\t\t\t\tframe.Height(),\n\t\t\t\t\tastiav.PixelFormatRgba,\n\t\t\t\t\tastiav.NewSoftwareScaleContextFlags(astiav.SoftwareScaleContextFlagBilinear),\n\t\t\t\t)\n\t\t\t\tif err != nil {\n\t\t\t\t\tframe.Unref()\n\t\t\t\t\treturn fmt.Errorf(\"webcam: creating software scale context failed: %w\", err)\n\t\t\t\t}\n\t\t\t\tdefer swsCtx.Free()\n\n\t\t\t\tdstFrame = astiav.AllocFrame()\n\t\t\t\tif dstFrame == nil {\n\t\t\t\t\tframe.Unref()\n\t\t\t\t\treturn fmt.Errorf(\"webcam: failed to allocate destination frame\")\n\t\t\t\t}\n\t\t\t\tdefer dstFrame.Free()\n\t\t\t}\n\n\t\t\t// Rate limiting for fps > 0\n\t\t\tif fps > 0 && !lastFrameTime.IsZero() {\n\t\t\t\telapsed := time.Since(lastFrameTime)\n\t\t\t\tif sleepDuration := frameDuration - elapsed; sleepDuration > 0 {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\t\tframe.Unref()\n\t\t\t\t\t\treturn ctx.Err()\n\t\t\t\t\tcase <-time.After(sleepDuration):\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tlastFrameTime = time.Now()\n\n\t\t\t// Scale frame to RGBA\n\t\t\tif err := swsCtx.ScaleFrame(frame, dstFrame); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"webcam: scaling frame failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Convert to Go image\n\t\t\timg, err := dstFrame.Data().GuessImageFormat()\n\t\t\tif err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"webcam: guessing image format failed: %w\", err)\n\t\t\t}\n\t\t\tif err := dstFrame.Data().ToImage(img); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn fmt.Errorf(\"webcam: converting frame to image failed: %w\", err)\n\t\t\t}\n\n\t\t\t// Apply filters and convert to paletted\n\t\t\tfilteredImg := redraw(img, p.c.Filter, p.c.Drawer)\n\n\t\t\t// Flush to output\n\t\t\tif err := flush(p.w, filteredImg, p.c.Flusher); err != nil {\n\t\t\t\tframe.Unref()\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Calculate rows for cursor reset\n\t\t\tif rows == 0 {\n\t\t\t\trows = filteredImg.Bounds().Dy() / 4\n\t\t\t\tif filteredImg.Bounds().Dy()%4 != 0 {\n\t\t\t\t\trows++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tp.c.Reset(p.w, rows)\n\t\t\tframe.Unref()\n\t\t}\n\t}\n}\n"
  }
]