Full Code of kevin-cantwell/dotmatrix for AI

main b9562229819b cached
17 files
61.4 KB
35.0k tokens
32 symbols
1 requests
Download .txt
Repository: kevin-cantwell/dotmatrix
Branch: main
Commit: b9562229819b
Files: 17
Total size: 61.4 KB

Directory structure:
gitextract_fwtecarx/

├── .github/
│   └── workflows/
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── braille.go
├── doc.go
├── dotmatrix_suite_test.go
├── ffmpeg_check.go
├── gif.go
├── go.mod
├── go.sum
├── image.go
├── image_test.go
├── mise.toml
├── mp4.go
└── webcam.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write

jobs:
  release:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-24.04
            artifact: dotmatrix-linux-amd64
          - os: macos-15
            artifact: dotmatrix-darwin-arm64
          - os: macos-15-intel
            artifact: dotmatrix-darwin-amd64

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install build dependencies (Linux)
        if: runner.os == 'Linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y nasm yasm pkg-config

      - name: Install build dependencies (macOS)
        if: runner.os == 'macOS'
        run: |
          brew install nasm pkg-config

      - name: Build FFmpeg 8.0.1 (minimal static)
        run: |
          curl -L https://ffmpeg.org/releases/ffmpeg-8.0.1.tar.xz | tar -xJ
          cd ffmpeg-8.0.1
          # Build minimal FFmpeg with only built-in codecs (no external libs)
          # This ensures fully static binary with no runtime dependencies
          ./configure \
            --enable-static \
            --disable-shared \
            --disable-programs \
            --disable-doc \
            --disable-network \
            --disable-encoders \
            --disable-muxers \
            --disable-outdevs \
            --disable-filters \
            --enable-filter=scale \
            --enable-filter=format \
            --disable-xlib \
            --disable-libxcb \
            --disable-sdl2 \
            --disable-lzma \
            --disable-zlib \
            --disable-bzlib \
            --disable-iconv \
            --disable-libxml2 \
            --disable-securetransport \
            --prefix=$HOME/ffmpeg
          make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu)
          make install

      - name: Get version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Build with static FFmpeg
        env:
          CGO_ENABLED: 1
        run: |
          export PKG_CONFIG_PATH="$HOME/ffmpeg/lib/pkgconfig"
          export CGO_LDFLAGS="$(pkg-config --static --libs libavdevice libavformat libavcodec libswscale libswresample libavutil)"
          export CGO_CFLAGS="$(pkg-config --cflags libavdevice libavformat libavcodec libswscale libswresample libavutil)"
          go build -ldflags "-X main.version=${{ steps.version.outputs.VERSION }}" -o dotmatrix ./cmd/dotmatrix

      - name: Ad-hoc code sign (macOS)
        if: runner.os == 'macOS'
        run: codesign --sign - --force dotmatrix

      - name: Create archive
        run: |
          tar -czvf ${{ matrix.artifact }}.tar.gz dotmatrix

      - name: Upload release asset
        uses: softprops/action-gh-release@v2
        with:
          files: ${{ matrix.artifact }}.tar.gz

  update-homebrew:
    needs: release
    runs-on: ubuntu-24.04
    steps:
      - name: Get version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Download release assets
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release download v${{ steps.version.outputs.VERSION }} \
            --repo kevin-cantwell/dotmatrix \
            --pattern '*.tar.gz' \
            --dir assets

      - name: Compute SHA256 checksums
        id: sha
        run: |
          echo "DARWIN_ARM64=$(sha256sum assets/dotmatrix-darwin-arm64.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT
          echo "DARWIN_AMD64=$(sha256sum assets/dotmatrix-darwin-amd64.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT
          echo "LINUX_AMD64=$(sha256sum assets/dotmatrix-linux-amd64.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT

      - name: Checkout homebrew-tap
        uses: actions/checkout@v4
        with:
          repository: kevin-cantwell/homebrew-tap
          ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}

      - name: Update formula
        env:
          VERSION: ${{ steps.version.outputs.VERSION }}
          SHA_DARWIN_ARM64: ${{ steps.sha.outputs.DARWIN_ARM64 }}
          SHA_DARWIN_AMD64: ${{ steps.sha.outputs.DARWIN_AMD64 }}
          SHA_LINUX_AMD64: ${{ steps.sha.outputs.LINUX_AMD64 }}
        run: |
          cat > Formula/dotmatrix.rb <<EOF
          class Dotmatrix < Formula
            desc "Convert images and videos to Unicode braille art in the terminal"
            homepage "https://github.com/kevin-cantwell/dotmatrix"
            version "${VERSION}"
            license "MIT"

            on_macos do
              on_arm do
                url "https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-darwin-arm64.tar.gz"
                sha256 "${SHA_DARWIN_ARM64}"
              end
              on_intel do
                url "https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-darwin-amd64.tar.gz"
                sha256 "${SHA_DARWIN_AMD64}"
              end
            end

            on_linux do
              on_intel do
                url "https://github.com/kevin-cantwell/dotmatrix/releases/download/v#{version}/dotmatrix-linux-amd64.tar.gz"
                sha256 "${SHA_LINUX_AMD64}"
              end
            end

            def install
              bin.install "dotmatrix"
            end

            test do
              assert_match version.to_s, shell_output("#{bin}/dotmatrix --version")
            end
          end
          EOF

      - name: Commit and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add Formula/dotmatrix.rb
          git commit -m "Update dotmatrix to ${{ steps.version.outputs.VERSION }}"
          git push


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  pull_request:
    branches: [master, main]

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install FFmpeg 7.x development libraries
        run: |
          sudo add-apt-repository -y ppa:ubuntuhandbook1/ffmpeg7
          sudo apt-get update
          sudo apt-get install -y libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libavdevice-dev libavfilter-dev

      - name: Build
        run: CGO_ENABLED=1 go build ./...

      - name: Test
        run: CGO_ENABLED=1 go test ./... -v


================================================
FILE: .gitignore
================================================
_ignore/
.DS_Store
dotmatrix


================================================
FILE: LICENSE.md
================================================
The MIT License (MIT)

Copyright (c) 2016 Kevin Cantwell

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Dotmatrix

[![Go Reference](https://pkg.go.dev/badge/github.com/kevin-cantwell/dotmatrix.svg)](https://pkg.go.dev/github.com/kevin-cantwell/dotmatrix)

Convert images to Unicode braille art for terminal display.

<table>
<tr>
<td><strong>Input</strong></td>
<td><strong>Output</strong></td>
</tr>
<tr>
<td><img src="https://cloud.githubusercontent.com/assets/307864/14945003/a928affe-0fd3-11e6-9725-ae6824be4317.png" alt="Input image" width="300"/></td>
<td><img src="https://cloud.githubusercontent.com/assets/307864/14945005/c9b0d53a-0fd3-11e6-9b06-841eb637a2a0.png" alt="Terminal output" width="300"/></td>
</tr>
</table>

## Features

- Encode JPEG, PNG, GIF, and BMP images as braille Unicode characters
- Animated GIF support with proper frame timing and disposal methods
- MP4 video playback with H.264 decoding
- Embedded subtitle rendering for MP4 files (mov_text format)
- Native webcam capture on macOS (AVFoundation)
- Image adjustments: gamma, brightness, contrast, sharpening
- Floyd-Steinberg dithering for grayscale preservation
- Automatic scaling to fit terminal dimensions

## Installation

### Homebrew (macOS/Linux)

```bash
brew install kevin-cantwell/tap/dotmatrix
```

### Pre-built Binaries

Download from [GitHub Releases](https://github.com/kevin-cantwell/dotmatrix/releases). Binaries are available for:
- Linux (amd64)
- macOS (arm64, amd64)

**macOS users:** The binaries are not signed with an Apple Developer account. After downloading, remove the quarantine attribute:

```bash
xattr -d com.apple.quarantine dotmatrix
```

### Install with Go

```bash
go install github.com/kevin-cantwell/dotmatrix/cmd/dotmatrix@latest
```

**macOS users:** The binary may be killed immediately due to an invalid code signature. If this happens, re-sign it:

```bash
codesign --force --sign - $(go env GOPATH)/bin/dotmatrix
```

### Building from Source

Building from source requires FFmpeg 8.x development libraries and CGO. Pre-built binaries have FFmpeg statically linked and require no runtime dependencies.

**macOS:**
```bash
brew install ffmpeg pkg-config
CGO_ENABLED=1 go build -o dotmatrix ./cmd/dotmatrix
```

**Ubuntu/Debian:**
```bash
# FFmpeg 8.x may need to be built from source if not available in repos
sudo apt-get install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libavdevice-dev
CGO_ENABLED=1 go build -o dotmatrix ./cmd/dotmatrix
```

## Usage

### Command Line

```bash
# From file
dotmatrix image.png

# From URL
dotmatrix https://example.com/image.jpg

# From stdin
curl -s https://example.com/image.jpg | dotmatrix

# With options
dotmatrix --invert --sharpen 50 image.png

# Play MP4 video (subtitles are displayed if embedded)
dotmatrix video.mp4

# Play MP4 at specific framerate
dotmatrix --fps 15 video.mp4

# Capture from webcam (macOS only)
dotmatrix --webcam

# Webcam with options
dotmatrix --webcam --invert --fps 15
```

### As a Library

```go
package main

import (
    "image"
    "os"

    "github.com/kevin-cantwell/dotmatrix"
)

func main() {
    img, _, _ := image.Decode(os.Stdin)
    dotmatrix.Print(os.Stdout, img)
}
```

## Options

| Flag | Description |
|------|-------------|
| `--invert`, `-i` | Invert colors (for dark backgrounds) |
| `--gamma`, `-g` | Adjust gamma: negative darkens, positive lightens |
| `--brightness`, `-b` | Adjust brightness (-100 to 100) |
| `--contrast`, `-c` | Adjust contrast (-100 to 100) |
| `--sharpen`, `-s` | Sharpen image |
| `--mirror`, `-m` | Flip image horizontally |
| `--mono` | Disable Floyd-Steinberg dithering |
| `--webcam`, `-w` | Capture from webcam (macOS only) |
| `--framerate`, `--fps` | Set playback framerate |
| `--mimeType`, `--mime` | Override auto-detected MIME type |

## Examples

### Sharpened Image

```bash
dotmatrix --sharpen 100 face.jpg
```

```
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣜⢽⡺⣿⣿⣺⣿⣏⣿⣿⣿⣿⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣜⡞⣧⢅⢳⡙⣼⣻⡢⡺⡼⣻⢞⡯⣟⣽⢻⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⣯⣿⣿⣿⡿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡜⣖⢔⠕⢸⡳⡢⡱⠅⡞⣽⠱⢳⢫⡟⡞⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣱⣿⣿⣵⡿⣿⣟⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣷⢿⡽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⣿⡿⡽⣿⢿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⢣⢫⠀⠣⠂⢘⠐⢁⢊⠠⠉⢎⢎⢣⢿⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⡼⡜⡾⡯⡫⢽⢽⣗⢟⣿⣿⣿⣿⣿⣿⣿⣟⡮⣟⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
⣿⣿⡿⡭⡺⣫⣟⡷⣟⢾⢽⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣎⠄⢊⢂⠠⠈⠄⠂⠀⢁⢑⠁⢜⡧⡗⢡⢮⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣜⢜⢌⢊⠈⣄⢜⢿⡫⣿⣿⣿⣿⣿⣿⣿⡿⡱⣻⢟⣿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
```

### Animated GIF

Animated GIFs play directly in the terminal with proper timing:

![Animated GIF example](https://cloud.githubusercontent.com/assets/307864/16272242/0dc3b6d8-386b-11e6-9ea3-e55ee936ae54.gif)

> **Note:** Terminal refresh rates vary. The default macOS Terminal.app works well; iTerm2 may have slower refresh rates.

### MP4 Subtitles

MP4 files with embedded subtitles (mov_text/tx3g format) will display subtitles overlaid at the bottom of the video. Subtitles are:

- Automatically extracted and timed to video playback
- Centered and wrapped to fit the terminal width
- Rendered over the braille output while preserving surrounding pixels

## How It Works

Dotmatrix 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.

**Processing pipeline:**

1. **Decode** - Parse input (JPEG, PNG, GIF, BMP, or MP4 video)
2. **Filter** - Apply brightness, contrast, gamma, sharpening adjustments
3. **Scale** - Resize to fit terminal dimensions (2 pixels per column, 4 pixels per row)
4. **Dither** - Convert to monochrome using Floyd-Steinberg diffusion
5. **Encode** - Map each 2x4 pixel block to a braille character
6. **Render** - Output braille characters with newlines (for video, frames are rendered in sequence)

The Floyd-Steinberg dithering algorithm distributes quantization errors to neighboring pixels, preserving the appearance of grayscale gradients in the monochrome output.

## License

MIT


================================================
FILE: braille.go
================================================
package dotmatrix

import (
	"image"
	"image/color"
	"io"
)

// Braille epresents an 8 dot braille pattern in x,y coordinates space. Eg:
//   +----------+
//   |(0,0)(1,0)|
//   |(0,1)(1,1)|
//   |(0,2)(1,2)|
//   |(0,3)(1,3)|
//   +----------+
type Braille [2][4]int

// Rune maps each point in braille to a dot identifier and
// calculates the corresponding unicode symbol.
//   +------+
//   |(1)(4)|
//   |(2)(5)|
//   |(3)(6)|
//   |(7)(8)|
//   +------+
// See https://en.wikipedia.org/wiki/Braille_Patterns#Identifying.2C_naming_and_ordering)
func (b Braille) Rune() rune {
	lowEndian := [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]}
	var v int
	for i, x := range lowEndian {
		v += int(x) << uint(i)
	}
	return rune(v) + '\u2800'
}

// String returns a unicode braille character. One of:
//  ⣿ ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⡈⡉⡊⡋⡌⡍⡎⡏⡐⡑⡒⡓⡔⡕⡖⡗⡘⡙⡚⡛⡜⡝⡞⡟⡠⡡⡢⡣⡤⡥⡦⡧⡨⡩⡪⡫⡬⡭⡮⡯⡰⡱⡲⡳⡴⡵⡶⡷⡸⡹⡺⡻⡼⡽⡾⡿⢀⢁⢂⢃⢄⢅⢆⢇⢈⢉⢊⢋⢌⢍⢎⢏⢐⢑⢒⢓⢔⢕⢖⢗⢘⢙⢚⢛⢜⢝⢞⢟⢠⢡⢢⢣⢤⢥⢦⢧⢨⢩⢪⢫⢬⢭⢮⢯⢰⢱⢲⢳⢴⢵⢶⢷⢸⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣈⣉⣊⣋⣌⣍⣎⣏⣐⣑⣒⣓⣔⣕⣖⣗⣘⣙⣚⣛⣜⣝⣞⣟⣠⣡⣢⣣⣤⣥⣦⣧⣨⣩⣪⣫⣬⣭⣮⣯⣰⣱⣲⣳⣴⣵⣶⣷⣸⣹⣺⣻⣼⣽⣾
func (b Braille) String() string {
	return string(b.Rune())
}

type BrailleFlusher struct{}

func (BrailleFlusher) Flush(w io.Writer, img image.Image) error {
	// An image's bounds do not necessarily start at (0, 0), so the two loops start
	// at bounds.Min.Y and bounds.Min.X.
	// Looping over Y first and X second is more likely to result in better memory
	// access patterns than X first and Y second.
	bounds := img.Bounds()
	for py := bounds.Min.Y; py < bounds.Max.Y; py += 4 {
		for px := bounds.Min.X; px < bounds.Max.X; px += 2 {
			var b Braille
			// Draw left-right, top-bottom.
			for y := 0; y < 4; y++ {
				for x := 0; x < 2; x++ {
					if px+x >= bounds.Max.X || py+y >= bounds.Max.Y {
						continue
					}
					// Always bet on black!
					if img.At(px+x, py+y) == color.Black {
						b[x][y] = 1
					}
				}
			}
			if _, err := w.Write([]byte(b.String())); err != nil {
				return err
			}
		}
		if _, err := w.Write([]byte{'\n'}); err != nil {
			return err
		}
	}
	return nil
}


================================================
FILE: doc.go
================================================
/*
Package dotmatrix encodes images in a "dot matrix" pattern using braille unicode characters.
Images are first converted to monochrome, then each 2x4 pixel block is coded to an
8-dot braille character. In this fashion, an image's entire pixel set can be mapped, one-by-one,
to either a "filled" or "unfilled" braille dot. The resulting braille symbols are arranged as
lines of text to form a representation of the original image. The encoded image is
lossy in the sense that color information is reduced to monochrome with diffusion, but otherwise
the resulting text-based image is pixel perfect. In other words, if a pure monochrome image
is used as input, there will be no loss of information.

https://en.wikipedia.org/wiki/Braille_Patterns⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡳⡧⡓⡝⣟⢿⢼⣿⠳⣿⣿⣿⢿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣼⣷⢿⣿⣿⢿⣻⣿⣺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣫⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣎⠷⡄⠣⠣⣺⡻⠨⡧⢛⣯⢟⢾⡵⣻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣻⠽⣫⣾⢿⢾⣟⣺⣻⣿⣿⣿⣿⣿⣿⣿⣾⣽⡝⣽⢻⡻⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿
  ⣿⣿⣿⣿⢟⣿⢿⣿⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡱⡱⡁⠈⡯⠘⢜⠘⢸⠈⡣⣳⢫⠹⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣕⡝⡻⣺⡾⢿⢮⡗⣟⣿⣿⣿⣿⣿⣿⣟⣗⢻⢝⣳⣏⣾⣿⣿⣿⣿⣽⣽⣟⣿⣿
  ⣿⣿⣿⢑⣝⢯⡯⣾⢻⣻⣽⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣅⠕⠀⠡⠀⠁⠌⠐⠈⠄⢣⠊⣾⡏⡟⢻⣿⣿⣿⣿⣿⣿⣿⣿⣯⡆⢍⠏⢓⠁⠃⣻⣵⢻⣿⣿⣿⣿⣿⣿⢞⢪⢾⣿⢫⣾⣿⣿⣿⣿⣷⣿⣿⣿⣿
  ⣿⣿⠣⢁⠪⡳⡫⣮⣱⢱⠹⣳⢻⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣨⠀⠀⠀⠁⠀⠀⠅⠅⢏⡻⡨⠪⢨⠻⣿⣿⣿⣿⣿⣿⣿⣿⣔⠈⢀⠔⠘⠈⡃⣙⣿⣿⣿⣿⣿⣿⣕⡘⢭⢎⡷⣟⣾⣾⣿⣿⡻⣵⢿⣾⣿
  ⣿⢇⡊⡄⡏⢉⡫⣫⢝⢮⠺⣔⠭⡚⢐⠜⢻⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀⠡⠃⠐⠈⠀⠁⠄⠹⢿⣿⣿⣿⣿⣿⣿⣷⡁⢂⠁⠀⢀⠐⠅⠚⣿⣿⣿⣿⣿⣿⠂⠢⡳⣻⣺⣿⢿⣿⣿⣟⣳⢻⣵⣿
  ⣿⡇⣫⠂⡀⠃⠀⠅⡝⣒⠳⠌⠕⠁⠂⠈⠀⠈⠈⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢘⣿⣿⣿⣿⣿⣿⣿⣮⠀⠀⠀⠀⠀⠀⢍⠹⣿⣿⣿⣿⣿⢅⢪⡽⡽⣹⣜⣽⣿⣿⡯⢺⢫⣻⣿
  ⣿⣾⣵⣶⣷⣬⣅⠅⠌⠘⡐⣈⠁⡀⠄⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠥⢐⣽⣿⣿⣿⣿⡣⠈⡩⣓⣵⡻⢽⣿⣿⢗⣕⣗⢷⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣭⣥⡉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣷⡆⠀⠀⠀⠀⠀⠈⢽⣿⣿⣿⢿⡕⠈⠴⠵⢵⢶⣿⣿⣿⡗⣪⣼⢞⣷
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡄⠀⠀⠀⠀⠀⠀⠈⢹⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⢼⣿⣿⣿⣷⠕⠈⠠⢦⢵⣻⣿⣿⣿⣞⣝⢿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠨⣿⣿⣿⣿⠡⠀⡈⠀⠩⣿⣿⣿⣟⢇⠃⣗⣿⣟
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠈⢻⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠈⣿⣿⣿⣿⠀⠀⠀⠀⠑⣿⣿⣿⣿⡥⠠⣽⣿⣿
  ⣿⣿⢯⢿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡀⠀⠀⠀⠀⠀⠀⠉⠻⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⣿⣿⣿⣷⠀⠀⠀⠀⢱⣿⣿⣿⣟⠎⣪⣿⣿⣪
  ⣿⣿⡯⡻⡙⡋⠉⠪⠁⠀⠀⠀⠀⠀⠀⠉⠉⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⡿⠀⠀⠀⠀⢿⣿⣿⣿⡫⢢⣿⣿⣯⢷
  ⣿⡿⣯⡪⡂⡄⢄⢁⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⢹⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⡇⠀⠀⠀⣐⣾⣿⡿⡧⢑⢶⣿⡟⣳⣿
  ⠀⠈⢳⣳⣽⣿⣾⣶⣿⣯⡶⢴⣴⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠉⢻⣿⣿⣿⣿⣧⠀⠀⠀⠀⠸⣿⣿⣿⣿⠀⠀⠀⠀⢀⣿⣿⣿⠇⠀⠀⠀⣽⣿⡿⡏⡐⢸⣿⣟⢳⣷⣿
  ⣤⣶⣽⣟⣿⣿⣿⣿⣿⢕⣽⢽⣺⢿⣿⣿⣿⣶⣦⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀⠙⠛⢿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣧⠀⠀⠀⠀⣿⣿⣿⣿⠀⠀⠀⠀⢰⣿⣿⣿⠁⠀⠀⢠⣿⣿⠽⠨⣮⣾⣿⣣⣿⣾⠟
  ⣷⣿⣿⣿⣿⣿⣿⣟⢎⠧⡫⣻⢟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⡀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠘⢿⣿⣿⣿⡄⠀⠀⠀⢹⣿⣿⣿⠀⠀⠀⠀⣼⣿⣿⡏⠀⠀⠀⣼⣿⠇⠀⣼⣿⡿⣪⣿⣿⠋⢀
  ⣿⣿⣿⣿⣿⣿⣿⠁⠁⠀⠀⠀⠀⠉⠉⠉⠛⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣦⡀⠀⠀⠈⢻⣿⣿⣷⡀⠀⠀⠈⣿⣿⣯⠀⠀⠀⢀⣿⣿⡿⠁⠀⠀⢰⣿⡟⠀⢰⣿⡟⢝⢸⡟⠁⣠⣾
  ⣿⣿⠏⠑⢽⡻⢑⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⢿⣿⣿⣿⣿⣿⣷⣦⣤⡀⠀⠀⠀⠈⠙⣿⣿⣿⣷⣄⠀⠀⠀⠻⣿⣿⣧⠀⠀⠀⢿⣿⣷⠀⠀⠀⢸⣿⣿⠃⠀⠀⢀⣿⣿⠁⠀⣾⠉⢓⠠⡿⠅⣴⣿⣿
  ⣿⣯⠀⢈⠕⡌⠆⠀⣠⣠⣤⣤⣤⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⢿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠙⢿⣿⣿⣦⡀⠀⠀⠹⣿⣿⠀⠀⠀⢹⣿⣿⠀⠀⠀⢸⣿⡿⠀⠀⠀⣼⣿⠃⠀⣸⠃⠀⠪⣾⡫⣲⣿⣿⣿
  ⣿⠁⠀⠀⢕⢨⣺⣽⢝⡮⡷⡵⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠿⣿⣿⣿⣶⣤⠀⠀⠈⠙⣿⣿⣷⣄⠀⠀⠻⣿⣇⠀⠀⢸⣿⣿⠀⠀⠀⣾⣿⠃⠀⠀⣼⣿⠃⠀⣼⠏⠀⢠⣿⣾⣿⣿⣿⣿⣿
  ⡿⠀⠀⢈⣦⣿⣿⡯⣽⣺⢝⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠉⠛⠻⢿⣿⣦⣀⠀⠈⠛⣿⣿⣦⡀⠀⠙⣿⡆⠀⠘⣿⣿⠀⠀⢐⣿⡟⠀⢀⣔⠟⠃⢀⡼⠋⠀⢀⣾⣿⣿⢿⣿⣿⠟⠁
  ⣿⠠⢲⣻⣽⣿⣿⡝⠚⠊⠓⠛⠛⠛⠛⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⢿⢿⣿⣿⣿⣶⣶⣤⣤⣀⠀⠀⠀⠀⠉⠛⠻⢷⣤⡀⠈⠙⢿⣷⣦⡀⠘⢿⡄⠀⣹⣿⣀⣀⣿⡋⢀⣠⠾⠋⣀⡴⠋⠁⠀⢠⣾⣿⠋⠃⠻⠛⠁⠀⠀
  ⣿⣴⣾⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠐⢐⣶⣶⣾⣾⢾⣶⣶⣶⣶⣶⣦⣭⣍⠛⢛⠛⠿⠿⣿⣷⣶⣦⣄⣀⠀⠀⠉⠛⠶⣦⢤⣽⣿⣷⣶⣾⣿⣿⣿⣿⣿⡿⢿⣿⣿⣿⡒⠚⠉⠁⢀⣤⣾⣿⣿⠓⠀⢰⠆⠀⠀⠀⠀
  ⣿⣿⣿⣿⣿⢟⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⣛⣿⠛⠃⠈⠻⣛⠛⠛⣻⠋⠛⣿⣿⣶⣬⡠⠀⠀⠀⠀⠉⠉⠙⠛⠛⠒⠲⠒⢒⣫⣵⢿⣿⠋⠙⠿⣋⣋⣻⠛⠁⠁⠛⣻⣯⣶⣶⣶⣾⣿⣿⠿⠛⠁⠀⢀⡺⡧⠀⣠⣴⣾
  ⣿⣿⣏⠁⠁⡃⠆⠀⠀⠀⢀⣠⣠⣤⣤⣶⣶⣿⣿⣿⣦⣄⣀⠈⠉⠉⠀⢀⣀⢻⣾⣿⣿⣿⣷⣦⣤⣤⣤⣤⣤⣤⣤⣤⣤⠖⠋⠉⣠⣟⠻⣱⣤⣤⣤⣅⣀⣤⣤⣶⣿⠿⠟⠛⠋⠉⠉⠉⠀⠀⠀⠀⢠⣞⣮⣿⣿⣿⣿⣿
  ⣿⣿⣿⣦⡀⠀⠁⢠⢔⢾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠋⠈⠀⠀⠈⠙⠙⠋⠙⠉⠉⠉⠉⠉⠁⣤⣶⠟⠋⣡⠔⣩⢺⢿⢻⠯⡙⠓⠶⣤⣤⣤⣤⣤⣤⣶⣶⣶⣶⣶⣶⣾⣿⢵⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣶⣷⣮⢇⢯⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠛⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⠛⠁⡴⠊⣡⠖⡅⣨⠏⡜⡄⠹⣶⣤⡀⠉⠉⠉⠋⠛⠛⠛⠛⠛⠛⠛⠋⠂⢸⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⡧⣫⢻⣿⣿⣿⠿⠛⠛⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣤⣤⣴⣶⣶⣶⣾⣿⡿⠛⠁⣠⡞⢀⡴⠁⡌⢠⡏⡎⢇⢻⡄⠉⠻⣿⣿⣶⣦⣤⣤⣤⣀⣀⡀⡀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⡪⠟⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣶⣿⣿⣿⣿⣿⣿⠿⠛⠛⠉⠁⠀⢠⣴⠛⢡⣾⠃⣼⠁⣼⡇⢧⠸⣤⠹⣦⡀⠀⠉⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⡪⣪⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣴⣾⣿⣿⣿⣿⠿⠛⠉⠉⠁⠀⠀⠀⠀⠀⣠⣶⡿⠃⣠⣿⠃⢠⡏⠀⣿⡇⢹⡇⠹⣧⠙⢿⣦⣄⠀⠀⠀⠈⠙⠛⠻⢿⣿⡿⡱⣽⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⣀⣠⣴⣶⣿⣿⣿⣿⣿⣿⡿⠛⠉⠁⠀⠀⠀⠀⠀⠀⢀⣠⣶⣿⣿⠏⠀⢰⣿⡏⠀⣼⠁⠀⣿⡇⠈⣿⠀⠹⣦⠈⠛⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠉⠀⣸⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⢀⣠⣶⣾⣿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⠟⠀⠀⢠⣾⡟⠀⢰⡟⠀⠀⣿⡇⠀⢻⣇⠀⠹⣷⡀⠈⠛⢿⣿⣿⣦⣄⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣶⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⡿⠛⠁⠀⠀⢠⣿⣿⠇⠀⢸⡇⠀⠐⣿⣷⠀⢸⣿⡆⠀⢹⣿⣄⠀⠈⠙⢿⣿⣿⣿⣶⣶⡴⣺⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣿⣿⣿⣽⠿⠋⠀⠀⠀⢀⣴⣿⣿⡿⠀⠀⣸⡇⠀⢸⣿⣿⠀⠀⣿⣷⠀⠀⢿⣿⣦⠀⠀⠀⠙⠻⣿⣿⣿⠕⣹⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠉⠀⠀⠀⠀⠀⠀⠀⣠⣴⣾⣿⣿⣿⡿⣫⣑⣦⣄⠀⠀⣠⣼⣿⣿⣟⣥⣴⣴⠟⠁⠀⢸⣿⣿⠀⠀⢹⣿⣦⠀⠈⢻⣿⣷⣄⠀⠀⠀⠈⠉⠁⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⡟⠋⠀⠀⠑⠁⢙⣶⣾⣿⣿⠟⠉⠉⢡⣿⣿⠀⠀⠀⢸⣿⣿⡄⠀⠀⣿⣿⣇⠀⠈⠻⣿⣿⣷⣄⣀⢀⠀⡀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⣰⣾⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⢀⣼⣿⣿⣿⠃⠀⠀⠀⢸⣿⣿⠀⠀⠀⠘⣿⣿⣷⡀⠀⢸⣿⣿⡄⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⠋⠀⠀⠀⠀⣾⣿⣿⠀⠀⠀⠀⣿⣿⣿⣧⠀⠀⢻⣿⣿⡄⠀⠀⠈⠛⠿⢿⠿⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⣶⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⡟⠀⠀⠀⠀⢠⣿⣿⡿⠀⠀⠀⠀⣿⣿⣿⣿⡄⠀⠀⢻⣿⣷⡄⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣾⣿⣿⣿⣿⣿⡿⠉⠀⠀⢀⡀⡀⣀⣠⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⣾⣿⣿⡯⠀⠀⠀⠀⣿⣿⣿⣿⣧⠀⠀⠈⣿⣿⣿⣦⡀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠉⠙⠛⣻⣿⣿⣿⣿⣿⠛⠓⠒⠒⠒⢳⣿⣿⣿⡟⠒⠒⠓⠛⣿⣿⣿⣿⣿⡄⠀⠀⠘⣿⣿⣿⣿⠗⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠈⢻⣿⠏⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⠀⠀⠀⠀⢸⣿⣿⣿⣿⣷⠀⠀⠀⠘⣿⡿⠉⠀⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣽⣿⡣⠀⠈⡋⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣧⠀⠀⠀⣸⡅⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣹⣾⣿⠂⠀⢐⠨⠢⡀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⡿⠁⠣⠲⠶⡲⢖⣾⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣇⣠⣼⣿⣷⡀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢏⢏⡾⣟⣿⠠⠀⠄⢂⠑⠔⡄⠀⢠⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡦⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡫⠣⡱⡵⣻⣿⣯⢃⠀⢐⠠⠈⡊⢼⣶⣾⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠈⢻⡿⠛⣿⣿⣿⣿⣿⣿⣿⣿⡥⡀⠀⠀⠑⠹⢿⣿⣿⣿⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⣿⣿⡿⡟⠝⡑⢌⢪⢪⢞⣯⡷⡯⡂⠄⠀⡂⠐⠨⡸⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⢀⠌⠀⠀⠘⢹⣿⣿⣿⣿⣿⣿⣿⣧⡂⠀⠀⠀⠁⠩⠻⣻⣿⣿⣿⣿⣿
  ⣿⣿⣿⣿⣿⢟⠫⡊⠌⢌⢐⢅⢎⢮⣻⣺⢽⢝⠔⢀⠡⠀⠌⡐⢼⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠠⢐⠁⠌⠠⠀⠀⠀⠙⢻⣿⣿⣿⣿⣿⣿⣾⡢⡀⠠⠐⢀⠡⠈⠝⡻⣿⣿⣿
  ⣿⡿⡟⡝⢌⠢⡑⠌⢌⠢⡂⡇⣗⢽⣺⡽⣽⢱⢁⠂⠄⡁⠂⠠⢱⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣶⡌⢈⠠⠈⠀⠂⠀⠀⠀⠀⠀⠛⢿⣿⣿⣿⣿⣿⣷⡪⡢⢈⠄⠂⢅⠑⢌⠢⠫⢿
  ⡫⡪⠪⡨⠢⡑⠌⢜⢐⢑⢌⢮⡺⣝⣗⣟⢮⢣⠂⠌⡠⠀⠡⠈⠠⣻⣿⣿⣿⣿⣿⣿⣿⣿⢑⢲⡰⡤⡤⣄⢼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⡀⠀⢀⠈⡀⠂⠀⠈⢀⠀⠀⠈⠻⢿⣿⣿⣿⣿⣯⣞⢔⠌⢌⢐⢈⠢⢑⠍⡪
  ⢸⠨⡊⡢⡑⢌⢊⠢⡑⡅⢇⠧⡯⣳⢯⣞⡝⡆⢅⠅⡂⠌⡐⢈⠀⢝⣿⣿⣿⣿⣿⣿⣿⣿⢜⠔⢕⢝⢜⢜⢜⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡐⠀⠄⢀⠈⠄⡁⠄⠠⠀⠀⠈⡈⢏⢿⣿⣿⣿⣯⣷⢍⢆⠢⡂⠌⡂⡑⢌
  ⡘⢌⠢⡑⢌⢢⠡⡃⢕⢸⢨⡫⣞⡽⣳⢵⢝⢜⢐⢐⢀⢂⠐⠄⢂⢐⢽⣿⣿⣿⣿⣿⣿⣿⡇⡕⢕⢌⢂⠕⡨⢚⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡡⠐⢀⠀⢂⠀⡂⠐⡀⠁⠄⢐⢀⠣⡫⣻⣿⣿⣿⣟⣎⡇⡆⢅⠢⡈⡢
  ⢸⠨⡊⡌⢆⢕⢑⠜⡌⡎⢮⢮⡳⣝⣗⣝⢮⢊⡂⡂⡢⠐⡈⢐⢀⠂⢽⣿⣿⣿⣿⣿⣿⣿⣯⡪⡪⡢⡑⢌⠔⡈⡺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡅⡂⠠⠀⠂⠀⠂⠄⠅⠨⠀⠄⢂⠪⡘⡎⡯⡿⣿⣷⣻⣜⢢⠡⢂⠢
  ⠢⡃⡪⡨⡒⡬⡢⡣⡣⡹⣪⡳⡽⣕⢗⡎⡎⡖⡨⢂⠢⡁⡂⡂⡐⠨⡘⣿⣿⣿⣿⣿⣿⣿⣿⢮⢢⢑⠌⣂⠪⢐⠸⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡔⡅⠌⠠⠈⡐⠀⠌⠠⠁⡂⢐⠨⠐⡡⢱⢹⢽⣿⢿⣺⣜⢌⠢⡨
  ⢕⢕⢜⡔⣕⢕⢕⢕⢕⢝⣜⢮⢳⢕⢗⡕⡇⡕⢌⠢⡑⡐⡐⠠⠐⠡⠨⣻⣿⣿⣿⣿⣿⣿⣿⣿⡰⢐⠌⡂⠅⠅⡘⠸⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡐⠄⠁⠄⠂⡈⠀⠅⡐⡐⠠⡁⡂⢅⢣⡫⣾⢿⣿⢾⡕⡕⡔
  ⡷⡯⣷⢽⡮⡷⣝⢮⢮⡣⣗⢵⢹⢪⡣⡣⡣⡊⡢⡑⢔⢐⠌⠄⠡⠁⠅⢯⣿⣿⣿⣿⣿⣿⣿⣷⡇⡂⠐⠀⡁⠅⢀⠁⠕⡻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣗⣕⠅⢌⠐⡀⠅⢂⠢⠨⡢⢢⢱⢱⢱⡹⡮⡿⣽⣟⣮⢷⢵
  ⢝⢝⢪⢫⢪⢫⢪⢫⢺⢺⢸⢪⢳⡱⡕⡕⡕⡌⡆⣊⠢⡂⡊⠨⢀⠁⡈⢎⣿⣿⣿⣿⣿⣿⣿⣿⣗⢄⠡⠀⠠⠈⢄⠐⡀⠌⢘⢝⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣾⡽⣦⡣⡐⡅⡢⡑⡕⡜⡎⡗⡝⡕⡝⡎⡏⢞⢜⢎⢏⢏
  ⡑⢅⠣⡊⡢⢑⠅⠕⢅⠣⡃⢇⢇⢇⢏⢪⠪⡊⡎⢔⢑⠔⢌⠌⠠⠐⡀⠕⣝⣿⣿⣿⣿⣿⣿⣿⣗⠅⠀⠀⠀⠌⠠⠂⠄⠠⠀⠄⠩⡫⡺⡯⣻⣝⢯⣟⢿⢽⡺⡯⣗⢯⢷⡱⡜⢌⠪⡘⡌⡪⠪⡘⢌⠢⡊⣊⢢⢑⢅⢒
*/
package dotmatrix


================================================
FILE: dotmatrix_suite_test.go
================================================
package dotmatrix_test

import (
	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"

	"testing"
)

func TestDotmatrix(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Dotmatrix Suite")
}


================================================
FILE: ffmpeg_check.go
================================================
package dotmatrix

/*
#cgo pkg-config: libavcodec libavformat libavutil

#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>

// Require FFmpeg 8.x (libavcodec >= 61, libavformat >= 61, libavutil >= 59)
// These version numbers correspond to FFmpeg n8.0
#if LIBAVCODEC_VERSION_MAJOR < 61
  #error "FFmpeg 8.0 or later is required. Found libavcodec version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html"
#endif

#if LIBAVFORMAT_VERSION_MAJOR < 61
  #error "FFmpeg 8.0 or later is required. Found libavformat version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html"
#endif

#if LIBAVUTIL_VERSION_MAJOR < 59
  #error "FFmpeg 8.0 or later is required. Found libavutil version too old. Please install FFmpeg 8.x: https://ffmpeg.org/download.html"
#endif
*/
import "C"


================================================
FILE: gif.go
================================================
package dotmatrix

import (
	"context"
	"image"
	"image/color"
	"image/gif"
	"io"
	"time"
)

type GIFPrinter struct {
	w io.Writer
	c Config
}

func NewGIFPrinter(w io.Writer, c *Config) *GIFPrinter {
	return &GIFPrinter{
		w: w,
		c: mergeConfig(c),
	}
}

/*
	Print animates a gif
*/
func (p *GIFPrinter) Print(ctx context.Context, giff *gif.GIF) error {
	if len(giff.Image) < 1 {
		return nil
	}

	// Only used if we see background disposal methods
	bgPallette := []color.Color{color.Transparent}
	if giff.Config.ColorModel != nil {
		bgPallette = giff.Config.ColorModel.(color.Palette)
	}

	// The screen is what we flush to the writer on each iteration
	screen := redraw(image.NewPaletted(giff.Image[0].Bounds(), bgPallette), p.c.Filter, p.c.Drawer)
	rows := screen.Bounds().Dy() / 4
	if screen.Bounds().Dy()%4 != 0 {
		rows++
	}

	for c := 0; giff.LoopCount == 0 || c < giff.LoopCount; c++ {
		for i := 0; i < len(giff.Image); i++ {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
			}

			delay := time.After(time.Duration(giff.Delay[i]) * time.Second / 100)

			frame := redraw(giff.Image[i], p.c.Filter, p.c.Drawer)

			switch giff.Disposal[i] {
			case gif.DisposalPrevious: // Dispose previous essentially means draw then undo
				temp := image.NewPaletted(screen.Bounds(), screen.Palette)
				copy(temp.Pix, screen.Pix)

				p.drawOver(screen, frame)
				if err := flush(p.w, screen, p.c.Flusher); err != nil {
					return err
				}
				<-delay

				screen = temp
			case gif.DisposalBackground: // Dispose background replaces everything just drawn with the background canvas
				background := redraw(image.NewPaletted(frame.Bounds(), bgPallette), p.c.Filter, p.c.Drawer)
				p.drawExact(screen, background)
				temp := image.NewPaletted(screen.Bounds(), screen.Palette)
				copy(temp.Pix, screen.Pix)

				p.drawOver(screen, frame)
				if err := flush(p.w, screen, p.c.Flusher); err != nil {
					return err
				}
				<-delay

				screen = temp
			default: // Dispose none or undefined means we just draw what we got over top
				p.drawOver(screen, frame)
				if err := flush(p.w, screen, p.c.Flusher); err != nil {
					return err
				}
				<-delay
			}

			p.c.Reset(p.w, rows)
		}
	}
	return nil
}

// Draws any non-transparent pixels into target
func (p *GIFPrinter) drawOver(target *image.Paletted, source image.Image) {
	bounds := source.Bounds()
	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
		for x := bounds.Min.X; x < bounds.Max.X; x++ {
			c := source.At(x, y)
			if c == color.Transparent {
				continue
			}
			target.Set(x, y, c)
		}
	}
}

// Draws pixels into target, including transparent ones.
func (p *GIFPrinter) drawExact(target *image.Paletted, source image.Image) {
	bounds := source.Bounds()
	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
		for x := bounds.Min.X; x < bounds.Max.X; x++ {
			target.Set(x, y, source.At(x, y))
		}
	}
}


================================================
FILE: go.mod
================================================
module github.com/kevin-cantwell/dotmatrix

go 1.22.0

toolchain go1.22.2

require (
	github.com/asticode/go-astiav v0.40.0
	github.com/disintegration/imaging v1.6.2
	github.com/onsi/ginkgo v1.16.5
	github.com/onsi/gomega v1.36.2
	github.com/urfave/cli/v2 v2.27.5
	golang.org/x/image v0.23.0
	golang.org/x/term v0.28.0
)

require (
	github.com/asticode/go-astikit v0.42.0 // indirect
	github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
	github.com/fsnotify/fsnotify v1.4.9 // indirect
	github.com/google/go-cmp v0.6.0 // indirect
	github.com/nxadm/tail v1.4.8 // indirect
	github.com/russross/blackfriday/v2 v2.1.0 // indirect
	github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
	golang.org/x/net v0.33.0 // indirect
	golang.org/x/sys v0.29.0 // indirect
	golang.org/x/text v0.21.0 // indirect
	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)


================================================
FILE: go.sum
================================================
github.com/asticode/go-astiav v0.40.0 h1:7El8FyONtjPOmcmqR+zjjihG7ysum5RtTVIhIMrLFQM=
github.com/asticode/go-astiav v0.40.0/go.mod h1:GI0pHw6K2/pl/o8upCtT49P/q4KCwhv/8nGLlCsZLdA=
github.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w=
github.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.22.1 h1:QW7tbJAUDyVDVOM5dFa7qaybo+CRfR7bemlQUN6Z8aM=
github.com/onsi/ginkgo/v2 v2.22.1/go.mod h1:S6aTpoRsSq2cZOd+pssHAlKW/Q/jZt6cPrPlnj4a1xM=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=


================================================
FILE: image.go
================================================
package dotmatrix

import (
	"fmt"
	"image"
	"image/color"
	"image/draw"
	"io"
)

// Flushes an image to the io.Writer. E.g. by using braille characters.
type Flusher interface {
	Flush(w io.Writer, img image.Image) error
}

// Filter may alter an image in any way, including resizing it.
// It is applied prior to drawing the image in the dotmatrix palette.
type Filter interface {
	Filter(image.Image) image.Image
}

type noop struct{}

func (noop) Filter(img image.Image) image.Image {
	return img
}

type Config struct {
	Filter  Filter
	Flusher Flusher
	Drawer  draw.Drawer
	// Reset is invoked between animated frames of an image. It can be used to
	// apply custom cursor positioning.
	Reset func(w io.Writer, rows int)
}

var defaultConfig = Config{
	Filter:  noop{},
	Flusher: BrailleFlusher{},
	Drawer:  draw.FloydSteinberg,
}

func mergeConfig(c *Config) Config {
	if c == nil {
		return defaultConfig
	}
	if c.Filter == nil {
		c.Filter = defaultConfig.Filter
	}
	if c.Drawer == nil {
		c.Drawer = defaultConfig.Drawer
	}
	if c.Flusher == nil {
		c.Flusher = defaultConfig.Flusher
	}
	if c.Reset == nil {
		c.Reset = func(w io.Writer, rows int) {
			fmt.Fprintf(w, "\033[999D\033[%dA", rows)
		}
	}
	return *c
}

var defaultPalette = []color.Color{color.Black, color.White, color.Transparent}

type Printer struct {
	w io.Writer
	c Config
}

func Print(w io.Writer, img image.Image) error {
	return NewPrinter(w, &defaultConfig).Print(img)
}

// NewPrinter provides an Printer. If drawer is nil, draw.FloydSteinberg is used.
func NewPrinter(w io.Writer, c *Config) *Printer {
	return &Printer{
		w: w,
		c: mergeConfig(c),
	}
}

/*
Print prints the image as a series of braille and line feed characters and writes
to w. Braille symbols are useful for representing monochrome images
because any 2x4 pixel area can be represented by one of unicode's
256 braille symbols. See: https://en.wikipedia.org/wiki/Braille_Patterns

Each pixel of the image is converted to either black or white by redrawing the
image using the printer's drawer (Floyd Steinberg diffusion, by default) and a
3-color palette of black, white, and transparent. Finally, each 2x4 pixel block
is printed as a braille symbol.

As an example, this output was printed from a 134px by 108px image of Saturn:
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡿⡻⡫⡫⡣⣣⢣⢇⢧⢫⢻⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡟⡟⣝⣜⠼⠼⢚⢚⢚⠓⠷⣧⣇⠧⡳⡱⣻⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡟⣏⡧⠧⠓⠍⡂⡂⠅⠌⠄⠄⠄⡁⠢⡈⣷⡹⡸⣪⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⠿⢿⢿⢿⢟⢏⡧⠗⡙⡐⡐⣌⢬⣒⣖⣼⣼⣸⢸⢐⢁⠂⡐⢰⡏⣎⢮⣾⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣾⣶⣿⢿⢻⡱⢕⠋⢅⠢⠱⢼⣾⣾⣿⣿⣿⣿⣿⣿⣿⡇⡇⠢⢁⢂⡯⡪⣪⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⠏⢎⠪⠨⡐⠔⠁⠁⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⢱⠡⡁⣢⢏⢮⣾⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢍⢆⢃⢑⠤⠑⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⡿⡱⢑⢐⢼⢱⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⢫⡱⢊⢂⢢⠢⡃⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⢟⢑⢌⢦⢫⣪⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡻⡱⡑⢅⢢⣢⣳⢱⢑⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⡑⡑⡴⡹⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢝⠜⠨⡐⣴⣵⣿⣗⡧⡣⠢⢈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣜⢎⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡫⡱⠑⡁⣌⣮⣾⣿⣿⣿⣟⡮⡪⡪⡐⠠⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢏⠜⠌⠄⣕⣼⣿⣿⣿⣿⣿⣿⣯⡯⣎⢖⠌⠌⠄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢨⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⠡⣸⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡽⡮⡪⡪⠨⡂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⢐⢔⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢽⡱⡱⡑⡠⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⢕⠕⢁⢐⢰⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣞⢜⠔⢄⠡⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⡿⡹⡰⠃⢈⠠⣢⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡮⣇⢏⢂⠢⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⢫⢒⡜⠐⠀⢢⣱⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣳⢕⢕⠌⠄⡀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⡿⡑⣅⠗⠀⡀⣥⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢙⠙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣯⢮⡪⣂⣢⣬⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⡟⡜⢌⡞⡀⣡⣾⣿⣿⣿⣿⣿⣿⣿⡿⠛⠉⢀⡠⠔⢜⣱⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⡿⡸⡘⢜⣧⣾⣿⣿⣿⣿⣿⣿⠿⢛⡡⠤⡒⢪⣑⣬⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⡇⡇⡣⣷⣿⣿⣿⣿⣿⠿⡛⡣⡋⣕⣬⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣮⣺⣿⣿⣟⣻⣩⣢⣵⣾⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
	⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿
*/
func (p *Printer) Print(img image.Image) error {
	img = redraw(img, p.c.Filter, p.c.Drawer)
	return flush(p.w, img, p.c.Flusher)
}

func redraw(img image.Image, filter Filter, drawer draw.Drawer) *image.Paletted {
	origBounds := img.Bounds()

	img = filter.Filter(img)

	newBounds := img.Bounds()

	scaleX := float64(newBounds.Dx()) / float64(origBounds.Dx())
	scaleY := float64(newBounds.Dy()) / float64(origBounds.Dy())

	// The offset is important because not all images have bounds starting at (0, 0), and
	// the filter may accidentally zero the min bounding point.
	offset := image.Pt(int(float64(origBounds.Min.X)*scaleX), int(float64(origBounds.Min.Y)*scaleY))

	// Create a new paletted image using a monochrome+transparent color palette.
	paletted := image.NewPaletted(img.Bounds(), defaultPalette)
	paletted.Rect = paletted.Bounds().Add(offset)
	drawer.Draw(paletted, paletted.Bounds(), img, img.Bounds().Min)
	return paletted
}

func flush(w io.Writer, img image.Image, flusher Flusher) error {
	return flusher.Flush(w, img)

}


================================================
FILE: image_test.go
================================================
package dotmatrix

// import (
// 	. "github.com/onsi/ginkgo"
// 	. "github.com/onsi/gomega"
// )

// // ⡪⣛
// //
// var testCanvas = canvas{
// 	{ // mx1
// 		braille{ // my1
// 			{white, black, white, black}, // x1,y1-4
// 			{black, white, black, white}, // x2,y1-4
// 		},
// 		braille{ // my1
// 			{black, black, black, black},                         // x1,y1-4
// 			{transparent, transparent, transparent, transparent}, // x2,y1-4
// 		},
// 	},
// 	{ // mx2
// 		braille{ // my2
// 			{black, black, white, black}, // x1,y1-4
// 			{black, black, white, black}, // x2,y1-4
// 		},
// 		braille{ // my2
// 			{transparent, transparent, transparent, transparent}, // x1,y1-4
// 			{black, black, black, black},                         // x2,y1-4
// 		},
// 	},
// }

// var _ = Describe("canvas", func() {
// 	Describe("#At", func() {
// 		It("Should return the color at the given coordinate.", func() {

// 			// copy(c, testCanvas)
// 			Expect("\n" + testCanvas.String()).To(Equal("\n⡪⣛\n"))
// 		})
// 	})
// })


================================================
FILE: mise.toml
================================================
[tools]
go = "1.22"


================================================
FILE: mp4.go
================================================
package dotmatrix

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"strings"
	"time"

	"github.com/asticode/go-astiav"
)

// subtitle holds a decoded subtitle with timing information.
type subtitle struct {
	startPTS int64
	endPTS   int64
	text     string
}

// MP4Printer prints MP4 video frames as braille characters.
type MP4Printer struct {
	w io.Writer
	c Config
}

// NewMP4Printer creates a new MP4Printer.
func NewMP4Printer(w io.Writer, c *Config) *MP4Printer {
	return &MP4Printer{
		w: w,
		c: mergeConfig(c),
	}
}

// Print plays an MP4 video from a file path. If fps is less than zero, it will
// use the video's native framerate. Otherwise, fps dictates how many frames per
// second are printed.
func (p *MP4Printer) Print(ctx context.Context, inputPath string, fps int) error {
	// Allocate packet
	pkt := astiav.AllocPacket()
	if pkt == nil {
		return fmt.Errorf("mp4: failed to allocate packet")
	}
	defer pkt.Free()

	// Allocate frame
	frame := astiav.AllocFrame()
	if frame == nil {
		return fmt.Errorf("mp4: failed to allocate frame")
	}
	defer frame.Free()

	// Allocate format context
	formatCtx := astiav.AllocFormatContext()
	if formatCtx == nil {
		return fmt.Errorf("mp4: failed to allocate format context")
	}
	defer formatCtx.Free()

	// Open input
	if err := formatCtx.OpenInput(inputPath, nil, nil); err != nil {
		return fmt.Errorf("mp4: opening input failed: %w", err)
	}
	defer formatCtx.CloseInput()

	// Find stream info
	if err := formatCtx.FindStreamInfo(nil); err != nil {
		return fmt.Errorf("mp4: finding stream info failed: %w", err)
	}

	// Find video and subtitle streams
	var videoStream *astiav.Stream
	var videoStreamIdx int
	var subtitleStream *astiav.Stream
	var subtitleStreamIdx int
	for _, s := range formatCtx.Streams() {
		switch s.CodecParameters().MediaType() {
		case astiav.MediaTypeVideo:
			if videoStream == nil {
				videoStream = s
				videoStreamIdx = s.Index()
			}
		case astiav.MediaTypeSubtitle:
			if subtitleStream == nil {
				subtitleStream = s
				subtitleStreamIdx = s.Index()
			}
		}
	}
	if videoStream == nil {
		return fmt.Errorf("mp4: no video stream found")
	}

	// Find decoder
	codec := astiav.FindDecoder(videoStream.CodecParameters().CodecID())
	if codec == nil {
		return fmt.Errorf("mp4: decoder not found for codec %s", videoStream.CodecParameters().CodecID())
	}

	// Allocate codec context
	codecCtx := astiav.AllocCodecContext(codec)
	if codecCtx == nil {
		return fmt.Errorf("mp4: failed to allocate codec context")
	}
	defer codecCtx.Free()

	// Copy codec parameters
	if err := videoStream.CodecParameters().ToCodecContext(codecCtx); err != nil {
		return fmt.Errorf("mp4: copying codec parameters failed: %w", err)
	}

	// Open codec
	if err := codecCtx.Open(codec, nil); err != nil {
		return fmt.Errorf("mp4: opening codec failed: %w", err)
	}

	// Set up subtitle storage if subtitle stream exists
	var subtitles []subtitle
	var subtitleTimeBase astiav.Rational
	if subtitleStream != nil {
		subtitleTimeBase = subtitleStream.TimeBase()
	}

	// Create software scale context for converting to RGBA
	var swsCtx *astiav.SoftwareScaleContext
	var dstFrame *astiav.Frame

	// Get stream time base for timing calculations
	timeBase := videoStream.TimeBase()

	// Calculate frame duration for fixed fps mode
	var frameDuration time.Duration
	if fps > 0 {
		frameDuration = time.Second / time.Duration(fps)
	}

	var rows int
	var playbackStart time.Time  // Wall clock time when playback started
	var firstPTS int64           // PTS of the first frame
	var frameCount int64         // Frame counter for fixed fps mode
	var initialized bool

	// Read packets
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		// Read frame
		if err := formatCtx.ReadFrame(pkt); err != nil {
			if errors.Is(err, astiav.ErrEof) {
				return nil
			}
			return fmt.Errorf("mp4: reading frame failed: %w", err)
		}

		// Handle subtitle packets
		if subtitleStream != nil && pkt.StreamIndex() == subtitleStreamIdx {
			// Parse mov_text subtitle format (2-byte length prefix + UTF-8 text)
			data := pkt.Data()
			if len(data) >= 2 {
				textLen := int(binary.BigEndian.Uint16(data[:2]))
				if textLen > 0 && len(data) >= 2+textLen {
					text := strings.TrimSpace(string(data[2 : 2+textLen]))
					if text != "" {
						// Convert subtitle PTS to video stream time base
						pktPTS := pkt.Pts()
						startPTS := pktPTS * int64(subtitleTimeBase.Num()) * int64(timeBase.Den()) / (int64(subtitleTimeBase.Den()) * int64(timeBase.Num()))
						// Duration from packet, convert to video time base
						pktDuration := pkt.Duration()
						durationPTS := pktDuration * int64(subtitleTimeBase.Num()) * int64(timeBase.Den()) / (int64(subtitleTimeBase.Den()) * int64(timeBase.Num()))
						endPTS := startPTS + durationPTS

						subtitles = append(subtitles, subtitle{
							startPTS: startPTS,
							endPTS:   endPTS,
							text:     text,
						})
					}
				}
			}
			pkt.Unref()
			continue
		}

		// Skip non-video packets
		if pkt.StreamIndex() != videoStreamIdx {
			pkt.Unref()
			continue
		}

		// Send packet to decoder
		if err := codecCtx.SendPacket(pkt); err != nil {
			pkt.Unref()
			continue
		}
		pkt.Unref()

		// Receive frames from decoder
		for {
			if err := codecCtx.ReceiveFrame(frame); err != nil {
				if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) {
					break
				}
				return fmt.Errorf("mp4: receiving frame failed: %w", err)
			}

			// Initialize scaler on first frame
			if swsCtx == nil {
				var err error
				swsCtx, err = astiav.CreateSoftwareScaleContext(
					frame.Width(),
					frame.Height(),
					frame.PixelFormat(),
					frame.Width(),
					frame.Height(),
					astiav.PixelFormatRgba,
					astiav.NewSoftwareScaleContextFlags(astiav.SoftwareScaleContextFlagBilinear),
				)
				if err != nil {
					frame.Unref()
					return fmt.Errorf("mp4: creating software scale context failed: %w", err)
				}
				defer swsCtx.Free()

				dstFrame = astiav.AllocFrame()
				if dstFrame == nil {
					frame.Unref()
					return fmt.Errorf("mp4: failed to allocate destination frame")
				}
				defer dstFrame.Free()
			}

			// Scale frame to RGBA
			if err := swsCtx.ScaleFrame(frame, dstFrame); err != nil {
				frame.Unref()
				return fmt.Errorf("mp4: scaling frame failed: %w", err)
			}

			// Convert to Go image
			img, err := dstFrame.Data().GuessImageFormat()
			if err != nil {
				frame.Unref()
				return fmt.Errorf("mp4: guessing image format failed: %w", err)
			}
			if err := dstFrame.Data().ToImage(img); err != nil {
				frame.Unref()
				return fmt.Errorf("mp4: converting frame to image failed: %w", err)
			}

			// Initialize timing on first frame
			if !initialized {
				playbackStart = time.Now()
				firstPTS = frame.Pts()
				initialized = true
			}

			// Calculate target display time and wait if needed
			var targetTime time.Time
			if fps > 0 {
				// Fixed framerate: target time based on frame count
				targetTime = playbackStart.Add(time.Duration(frameCount) * frameDuration)
			} else if fps < 0 {
				// Native timing: target time based on PTS
				ptsDiff := frame.Pts() - firstPTS
				videoTime := time.Duration(float64(ptsDiff) * float64(timeBase.Num()) / float64(timeBase.Den()) * float64(time.Second))
				targetTime = playbackStart.Add(videoTime)
			}

			// Sleep until target time (if we're ahead of schedule)
			if sleepDuration := time.Until(targetTime); sleepDuration > 0 {
				select {
				case <-ctx.Done():
					frame.Unref()
					return ctx.Err()
				case <-time.After(sleepDuration):
				}
			}
			frameCount++

			// Apply filters and convert to paletted
			filteredImg := redraw(img, p.c.Filter, p.c.Drawer)

			// Flush to output
			if err := flush(p.w, filteredImg, p.c.Flusher); err != nil {
				frame.Unref()
				return err
			}

			// Calculate rows for cursor reset
			if rows == 0 {
				rows = filteredImg.Bounds().Dy() / 4
				if filteredImg.Bounds().Dy()%4 != 0 {
					rows++
				}
			}

			// Overlay subtitle on the bottom rows of the braille output
			imageWidthInChars := filteredImg.Bounds().Dx() / 2
			currentPTS := frame.Pts()
			activeSubtitle := findActiveSubtitle(subtitles, currentPTS)
			if activeSubtitle != "" {
				// Wrap and limit subtitle lines (max 3 lines, leave 1 line of braille visible at top minimum)
				maxLines := 3
				if rows-1 < maxLines {
					maxLines = rows - 1
				}
				if maxLines < 1 {
					maxLines = 1
				}
				lines := wrapText(activeSubtitle, imageWidthInChars, maxLines)
				numLines := len(lines)

				// Move cursor up to position subtitles at the bottom of the frame
				// We go up (numLines) lines from the current position (which is after the last row)
				fmt.Fprintf(p.w, "\033[%dA", numLines)

				// Write each line centered (preserving braille on left and right)
				for i, line := range lines {
					// Calculate column position for centered text (1-indexed for ANSI)
					lineLen := len([]rune(line))
					startCol := (imageWidthInChars - lineLen) / 2
					if startCol < 1 {
						startCol = 1
					}
					// Move cursor to the start column and write text (preserves braille on sides)
					fmt.Fprintf(p.w, "\033[%dG%s", startCol, line)
					if i < numLines-1 {
						fmt.Fprint(p.w, "\n")
					}
				}

				// Move cursor back down to the original position
				// We're now on the last subtitle line (which is the last row of the frame)
				// So we only need to move down 1 line to get back to where we started
				fmt.Fprint(p.w, "\033[1B\r")
			}

			p.c.Reset(p.w, rows)
			frame.Unref()
		}
	}
}

// findActiveSubtitle returns the subtitle text that should be displayed at the given PTS.
func findActiveSubtitle(subtitles []subtitle, pts int64) string {
	for _, s := range subtitles {
		if pts >= s.startPTS && pts < s.endPTS {
			return s.text
		}
	}
	return ""
}

// wrapText wraps text to fit within width, limiting to maxLines.
// If text exceeds maxLines, the last line is truncated with ellipsis.
func wrapText(text string, width, maxLines int) []string {
	if width <= 0 || maxLines <= 0 {
		return nil
	}

	text = strings.TrimSpace(text)
	if text == "" {
		return nil
	}

	words := strings.Fields(text)
	if len(words) == 0 {
		return nil
	}

	var lines []string
	var currentLine string

	for _, word := range words {
		wordRunes := []rune(word)

		// If single word is longer than width, we need to handle it specially
		if len(wordRunes) > width {
			// Flush current line if not empty
			if currentLine != "" {
				lines = append(lines, currentLine)
				currentLine = ""
			}

			// Break the long word across lines
			for len(wordRunes) > 0 {
				if len(lines) >= maxLines {
					break
				}
				take := width
				if take > len(wordRunes) {
					take = len(wordRunes)
				}
				lines = append(lines, string(wordRunes[:take]))
				wordRunes = wordRunes[take:]
			}
			continue
		}

		// Check if word fits on current line
		if currentLine == "" {
			currentLine = word
		} else if len([]rune(currentLine))+1+len(wordRunes) <= width {
			currentLine += " " + word
		} else {
			// Word doesn't fit, start new line
			lines = append(lines, currentLine)
			currentLine = word
		}
	}

	// Don't forget the last line
	if currentLine != "" {
		lines = append(lines, currentLine)
	}

	// Limit to maxLines and add ellipsis if truncated
	if len(lines) > maxLines {
		lines = lines[:maxLines]
		// Add ellipsis to last line
		lastLine := []rune(lines[maxLines-1])
		if len(lastLine)+3 <= width {
			lines[maxLines-1] = string(lastLine) + "..."
		} else if len(lastLine) > 3 {
			lines[maxLines-1] = string(lastLine[:len(lastLine)-3]) + "..."
		}
	}

	return lines
}


================================================
FILE: webcam.go
================================================
package dotmatrix

import (
	"context"
	"errors"
	"fmt"
	"io"
	"strings"
	"time"

	"github.com/asticode/go-astiav"
)

// WebcamPrinter prints webcam frames as braille characters.
type WebcamPrinter struct {
	w io.Writer
	c Config
}

// NewWebcamPrinter creates a new WebcamPrinter.
func NewWebcamPrinter(w io.Writer, c *Config) *WebcamPrinter {
	return &WebcamPrinter{
		w: w,
		c: mergeConfig(c),
	}
}

// Print captures frames from the webcam and renders them as braille.
// If fps is less than zero, frames are rendered as fast as they arrive.
// Otherwise, fps dictates how many frames per second are printed.
func (p *WebcamPrinter) Print(ctx context.Context, fps int) error {
	// Register all devices to enable AVFoundation
	astiav.RegisterAllDevices()

	// Find AVFoundation input format (macOS)
	inputFormat := astiav.FindInputFormat("avfoundation")
	if inputFormat == nil {
		return fmt.Errorf("webcam: avfoundation input format not found (macOS only)")
	}

	// Set up options
	options := astiav.NewDictionary()
	defer options.Free()

	// Set framerate - default to 30 if not specified (AVFoundation requires exact fps)
	if fps <= 0 {
		fps = 30
	}
	options.Set("framerate", fmt.Sprintf("%d", fps), 0)

	// Set pixel format to one supported by most cameras
	options.Set("pixel_format", "uyvy422", 0)

	// Increase probesize to give AVFoundation time to initialize
	options.Set("probesize", "32000000", 0)

	// Allocate format context
	formatCtx := astiav.AllocFormatContext()
	if formatCtx == nil {
		return fmt.Errorf("webcam: failed to allocate format context")
	}
	defer formatCtx.Free()

	// Open webcam device (device "0" is the first video device)
	if err := formatCtx.OpenInput("0", inputFormat, options); err != nil {
		// Provide helpful error messages for common issues
		errStr := err.Error()
		if errStr == "Operation not permitted" || errStr == "Permission denied" {
			return fmt.Errorf("webcam: permission denied - check System Preferences > Privacy & Security > Camera")
		}
		if errStr == "Device or resource busy" {
			return fmt.Errorf("webcam: camera is in use by another application")
		}
		return fmt.Errorf("webcam: failed to open camera: %w", err)
	}
	defer formatCtx.CloseInput()

	// Find stream info
	if err := formatCtx.FindStreamInfo(nil); err != nil {
		return fmt.Errorf("webcam: finding stream info failed: %w", err)
	}

	// Find video stream
	var videoStream *astiav.Stream
	var videoStreamIdx int
	for _, s := range formatCtx.Streams() {
		if s.CodecParameters().MediaType() == astiav.MediaTypeVideo {
			videoStream = s
			videoStreamIdx = s.Index()
			break
		}
	}
	if videoStream == nil {
		return fmt.Errorf("webcam: no video stream found")
	}

	// Find decoder
	codec := astiav.FindDecoder(videoStream.CodecParameters().CodecID())
	if codec == nil {
		return fmt.Errorf("webcam: decoder not found for codec %s", videoStream.CodecParameters().CodecID())
	}

	// Allocate codec context
	codecCtx := astiav.AllocCodecContext(codec)
	if codecCtx == nil {
		return fmt.Errorf("webcam: failed to allocate codec context")
	}
	defer codecCtx.Free()

	// Copy codec parameters
	if err := videoStream.CodecParameters().ToCodecContext(codecCtx); err != nil {
		return fmt.Errorf("webcam: copying codec parameters failed: %w", err)
	}

	// Open codec
	if err := codecCtx.Open(codec, nil); err != nil {
		return fmt.Errorf("webcam: opening codec failed: %w", err)
	}

	// Allocate packet
	pkt := astiav.AllocPacket()
	if pkt == nil {
		return fmt.Errorf("webcam: failed to allocate packet")
	}
	defer pkt.Free()

	// Allocate frame
	frame := astiav.AllocFrame()
	if frame == nil {
		return fmt.Errorf("webcam: failed to allocate frame")
	}
	defer frame.Free()

	// Create software scale context for converting to RGBA
	var swsCtx *astiav.SoftwareScaleContext
	var dstFrame *astiav.Frame

	// Calculate frame duration for fps limiting
	var frameDuration time.Duration
	if fps > 0 {
		frameDuration = time.Second / time.Duration(fps)
	}

	var rows int
	var lastFrameTime time.Time

	// Read frames
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		// Read frame
		if err := formatCtx.ReadFrame(pkt); err != nil {
			if errors.Is(err, astiav.ErrEof) {
				return nil
			}
			// EAGAIN means no frame available yet, just retry
			if errors.Is(err, astiav.ErrEagain) {
				continue
			}
			// Also check error string for "Resource temporarily unavailable" (EAGAIN on some systems)
			if strings.Contains(err.Error(), "Resource temporarily unavailable") {
				continue
			}
			return fmt.Errorf("webcam: reading frame failed: %w", err)
		}

		// Skip non-video packets
		if pkt.StreamIndex() != videoStreamIdx {
			pkt.Unref()
			continue
		}

		// Send packet to decoder
		if err := codecCtx.SendPacket(pkt); err != nil {
			pkt.Unref()
			continue
		}
		pkt.Unref()

		// Receive frames from decoder
		for {
			if err := codecCtx.ReceiveFrame(frame); err != nil {
				if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) {
					break
				}
				return fmt.Errorf("webcam: receiving frame failed: %w", err)
			}

			// Initialize scaler on first frame
			if swsCtx == nil {
				var err error
				swsCtx, err = astiav.CreateSoftwareScaleContext(
					frame.Width(),
					frame.Height(),
					frame.PixelFormat(),
					frame.Width(),
					frame.Height(),
					astiav.PixelFormatRgba,
					astiav.NewSoftwareScaleContextFlags(astiav.SoftwareScaleContextFlagBilinear),
				)
				if err != nil {
					frame.Unref()
					return fmt.Errorf("webcam: creating software scale context failed: %w", err)
				}
				defer swsCtx.Free()

				dstFrame = astiav.AllocFrame()
				if dstFrame == nil {
					frame.Unref()
					return fmt.Errorf("webcam: failed to allocate destination frame")
				}
				defer dstFrame.Free()
			}

			// Rate limiting for fps > 0
			if fps > 0 && !lastFrameTime.IsZero() {
				elapsed := time.Since(lastFrameTime)
				if sleepDuration := frameDuration - elapsed; sleepDuration > 0 {
					select {
					case <-ctx.Done():
						frame.Unref()
						return ctx.Err()
					case <-time.After(sleepDuration):
					}
				}
			}
			lastFrameTime = time.Now()

			// Scale frame to RGBA
			if err := swsCtx.ScaleFrame(frame, dstFrame); err != nil {
				frame.Unref()
				return fmt.Errorf("webcam: scaling frame failed: %w", err)
			}

			// Convert to Go image
			img, err := dstFrame.Data().GuessImageFormat()
			if err != nil {
				frame.Unref()
				return fmt.Errorf("webcam: guessing image format failed: %w", err)
			}
			if err := dstFrame.Data().ToImage(img); err != nil {
				frame.Unref()
				return fmt.Errorf("webcam: converting frame to image failed: %w", err)
			}

			// Apply filters and convert to paletted
			filteredImg := redraw(img, p.c.Filter, p.c.Drawer)

			// Flush to output
			if err := flush(p.w, filteredImg, p.c.Flusher); err != nil {
				frame.Unref()
				return err
			}

			// Calculate rows for cursor reset
			if rows == 0 {
				rows = filteredImg.Bounds().Dy() / 4
				if filteredImg.Bounds().Dy()%4 != 0 {
					rows++
				}
			}

			p.c.Reset(p.w, rows)
			frame.Unref()
		}
	}
}
Download .txt
gitextract_fwtecarx/

├── .github/
│   └── workflows/
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── braille.go
├── doc.go
├── dotmatrix_suite_test.go
├── ffmpeg_check.go
├── gif.go
├── go.mod
├── go.sum
├── image.go
├── image_test.go
├── mise.toml
├── mp4.go
└── webcam.go
Download .txt
SYMBOL INDEX (32 symbols across 6 files)

FILE: braille.go
  type Braille (line 16) | type Braille
    method Rune (line 27) | func (b Braille) Rune() rune {
    method String (line 38) | func (b Braille) String() string {
  type BrailleFlusher (line 42) | type BrailleFlusher struct
    method Flush (line 44) | func (BrailleFlusher) Flush(w io.Writer, img image.Image) error {

FILE: dotmatrix_suite_test.go
  function TestDotmatrix (line 10) | func TestDotmatrix(t *testing.T) {

FILE: gif.go
  type GIFPrinter (line 12) | type GIFPrinter struct
    method Print (line 27) | func (p *GIFPrinter) Print(ctx context.Context, giff *gif.GIF) error {
    method drawOver (line 97) | func (p *GIFPrinter) drawOver(target *image.Paletted, source image.Ima...
    method drawExact (line 111) | func (p *GIFPrinter) drawExact(target *image.Paletted, source image.Im...
  function NewGIFPrinter (line 17) | func NewGIFPrinter(w io.Writer, c *Config) *GIFPrinter {

FILE: image.go
  type Flusher (line 12) | type Flusher interface
  type Filter (line 18) | type Filter interface
  type noop (line 22) | type noop struct
    method Filter (line 24) | func (noop) Filter(img image.Image) image.Image {
  type Config (line 28) | type Config struct
  function mergeConfig (line 43) | func mergeConfig(c *Config) Config {
  type Printer (line 66) | type Printer struct
    method Print (line 123) | func (p *Printer) Print(img image.Image) error {
  function Print (line 71) | func Print(w io.Writer, img image.Image) error {
  function NewPrinter (line 76) | func NewPrinter(w io.Writer, c *Config) *Printer {
  function redraw (line 128) | func redraw(img image.Image, filter Filter, drawer draw.Drawer) *image.P...
  function flush (line 149) | func flush(w io.Writer, img image.Image, flusher Flusher) error {

FILE: mp4.go
  type subtitle (line 16) | type subtitle struct
  type MP4Printer (line 23) | type MP4Printer struct
    method Print (line 39) | func (p *MP4Printer) Print(ctx context.Context, inputPath string, fps ...
  function NewMP4Printer (line 29) | func NewMP4Printer(w io.Writer, c *Config) *MP4Printer {
  function findActiveSubtitle (line 349) | func findActiveSubtitle(subtitles []subtitle, pts int64) string {
  function wrapText (line 360) | func wrapText(text string, width, maxLines int) []string {

FILE: webcam.go
  type WebcamPrinter (line 15) | type WebcamPrinter struct
    method Print (line 31) | func (p *WebcamPrinter) Print(ctx context.Context, fps int) error {
  function NewWebcamPrinter (line 21) | func NewWebcamPrinter(w io.Writer, c *Config) *WebcamPrinter {
Condensed preview — 17 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (82K chars).
[
  {
    "path": ".github/workflows/release.yml",
    "chars": 5966,
    "preview": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\npermissions:\n  contents: write\n\njobs:\n  release:\n    strategy:\n      "
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 686,
    "preview": "name: Test\n\non:\n  pull_request:\n    branches: [master, main]\n\njobs:\n  test:\n    runs-on: ubuntu-24.04\n    steps:\n      -"
  },
  {
    "path": ".gitignore",
    "chars": 29,
    "preview": "_ignore/\n.DS_Store\ndotmatrix\n"
  },
  {
    "path": "LICENSE.md",
    "chars": 1081,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2016 Kevin Cantwell\n\nPermission is hereby granted, free of charge, to any person ob"
  },
  {
    "path": "README.md",
    "chars": 5849,
    "preview": "# Dotmatrix\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/kevin-cantwell/dotmatrix.svg)](https://pkg.go.dev/gith"
  },
  {
    "path": "braille.go",
    "chars": 2091,
    "preview": "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 coordi"
  },
  {
    "path": "doc.go",
    "chars": 5812,
    "preview": "/*\nPackage dotmatrix encodes images in a \"dot matrix\" pattern using braille unicode characters.\nImages are first convert"
  },
  {
    "path": "dotmatrix_suite_test.go",
    "chars": 200,
    "preview": "package dotmatrix_test\n\nimport (\n\t. \"github.com/onsi/ginkgo\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"testing\"\n)\n\nfunc TestDotmatr"
  },
  {
    "path": "ffmpeg_check.go",
    "chars": 851,
    "preview": "package dotmatrix\n\n/*\n#cgo pkg-config: libavcodec libavformat libavutil\n\n#include <libavcodec/avcodec.h>\n#include <libav"
  },
  {
    "path": "gif.go",
    "chars": 2891,
    "preview": "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"
  },
  {
    "path": "go.mod",
    "chars": 921,
    "preview": "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.4"
  },
  {
    "path": "go.sum",
    "chars": 11468,
    "preview": "github.com/asticode/go-astiav v0.40.0 h1:7El8FyONtjPOmcmqR+zjjihG7ysum5RtTVIhIMrLFQM=\ngithub.com/asticode/go-astiav v0.4"
  },
  {
    "path": "image.go",
    "chars": 5189,
    "preview": "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. "
  },
  {
    "path": "image_test.go",
    "chars": 1025,
    "preview": "package dotmatrix\n\n// import (\n// \t. \"github.com/onsi/ginkgo\"\n// \t. \"github.com/onsi/gomega\"\n// )\n\n// // ⡪⣛\n// //\n// var"
  },
  {
    "path": "mise.toml",
    "chars": 20,
    "preview": "[tools]\ngo = \"1.22\"\n"
  },
  {
    "path": "mp4.go",
    "chars": 11659,
    "preview": "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/astico"
  },
  {
    "path": "webcam.go",
    "chars": 7093,
    "preview": "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//"
  }
]

About this extraction

This page contains the full source code of the kevin-cantwell/dotmatrix GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 17 files (61.4 KB), approximately 35.0k tokens, and a symbol index with 32 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!