image.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. package image
  2. import (
  3. "bytes"
  4. "fmt"
  5. "hash/fnv"
  6. "image"
  7. "image/color"
  8. "io"
  9. "log/slog"
  10. "strings"
  11. "sync"
  12. tea "charm.land/bubbletea/v2"
  13. "github.com/charmbracelet/crush/internal/ui/util"
  14. "github.com/charmbracelet/x/ansi"
  15. "github.com/charmbracelet/x/ansi/kitty"
  16. "github.com/disintegration/imaging"
  17. paintbrush "github.com/jordanella/go-ansi-paintbrush"
  18. )
  19. // TransmittedMsg is a message indicating that an image has been transmitted to
  20. // the terminal.
  21. type TransmittedMsg struct {
  22. ID string
  23. }
  24. // Encoding represents the encoding format of the image.
  25. type Encoding byte
  26. // Image encodings.
  27. const (
  28. EncodingBlocks Encoding = iota
  29. EncodingKitty
  30. )
  31. type imageKey struct {
  32. id string
  33. cols int
  34. rows int
  35. }
  36. // Hash returns a hash value for the image key.
  37. // This uses FNV-32a for simplicity and speed.
  38. func (k imageKey) Hash() uint32 {
  39. h := fnv.New32a()
  40. _, _ = io.WriteString(h, k.ID())
  41. return h.Sum32()
  42. }
  43. // ID returns a unique string representation of the image key.
  44. func (k imageKey) ID() string {
  45. return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
  46. }
  47. // CellSize represents the size of a single terminal cell in pixels.
  48. type CellSize struct {
  49. Width, Height int
  50. }
  51. type cachedImage struct {
  52. img image.Image
  53. cols, rows int
  54. }
  55. var (
  56. cachedImages = map[imageKey]cachedImage{}
  57. cachedMutex sync.RWMutex
  58. )
  59. // ResetCache clears the image cache, freeing all cached decoded images.
  60. func ResetCache() {
  61. cachedMutex.Lock()
  62. clear(cachedImages)
  63. cachedMutex.Unlock()
  64. }
  65. // fitImage resizes the image to fit within the specified dimensions in
  66. // terminal cells, maintaining the aspect ratio.
  67. func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
  68. if img == nil {
  69. return nil
  70. }
  71. key := imageKey{id: id, cols: cols, rows: rows}
  72. cachedMutex.RLock()
  73. cached, ok := cachedImages[key]
  74. cachedMutex.RUnlock()
  75. if ok {
  76. return cached.img
  77. }
  78. if cs.Width == 0 || cs.Height == 0 {
  79. return img
  80. }
  81. maxWidth := cols * cs.Width
  82. maxHeight := rows * cs.Height
  83. img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
  84. cachedMutex.Lock()
  85. cachedImages[key] = cachedImage{
  86. img: img,
  87. cols: cols,
  88. rows: rows,
  89. }
  90. cachedMutex.Unlock()
  91. return img
  92. }
  93. // HasTransmitted checks if the image with the given ID has already been
  94. // transmitted to the terminal.
  95. func HasTransmitted(id string, cols, rows int) bool {
  96. key := imageKey{id: id, cols: cols, rows: rows}
  97. cachedMutex.RLock()
  98. _, ok := cachedImages[key]
  99. cachedMutex.RUnlock()
  100. return ok
  101. }
  102. // Transmit transmits the image data to the terminal if needed. This is used to
  103. // cache the image on the terminal for later rendering.
  104. func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
  105. if img == nil {
  106. return nil
  107. }
  108. key := imageKey{id: id, cols: cols, rows: rows}
  109. cachedMutex.RLock()
  110. _, ok := cachedImages[key]
  111. cachedMutex.RUnlock()
  112. if ok {
  113. return nil
  114. }
  115. cmd := func() tea.Msg {
  116. if e != EncodingKitty {
  117. cachedMutex.Lock()
  118. cachedImages[key] = cachedImage{
  119. img: img,
  120. cols: cols,
  121. rows: rows,
  122. }
  123. cachedMutex.Unlock()
  124. return TransmittedMsg{ID: key.ID()}
  125. }
  126. var buf bytes.Buffer
  127. img := fitImage(id, img, cs, cols, rows)
  128. bounds := img.Bounds()
  129. imgWidth := bounds.Dx()
  130. imgHeight := bounds.Dy()
  131. imgID := int(key.Hash())
  132. if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
  133. ID: imgID,
  134. Action: kitty.TransmitAndPut,
  135. Transmission: kitty.Direct,
  136. Format: kitty.RGBA,
  137. ImageWidth: imgWidth,
  138. ImageHeight: imgHeight,
  139. Columns: cols,
  140. Rows: rows,
  141. VirtualPlacement: true,
  142. Quite: 1,
  143. Chunk: true,
  144. ChunkFormatter: func(chunk string) string {
  145. if tmux {
  146. return ansi.TmuxPassthrough(chunk)
  147. }
  148. return chunk
  149. },
  150. }); err != nil {
  151. slog.Error("Failed to encode image for kitty graphics", "err", err)
  152. return util.InfoMsg{
  153. Type: util.InfoTypeError,
  154. Msg: "failed to encode image",
  155. }
  156. }
  157. return tea.RawMsg{Msg: buf.String()}
  158. }
  159. return cmd
  160. }
  161. // Render renders the given image within the specified dimensions using the
  162. // specified encoding.
  163. func (e Encoding) Render(id string, cols, rows int) string {
  164. key := imageKey{id: id, cols: cols, rows: rows}
  165. cachedMutex.RLock()
  166. cached, ok := cachedImages[key]
  167. cachedMutex.RUnlock()
  168. if !ok {
  169. return ""
  170. }
  171. img := cached.img
  172. switch e {
  173. case EncodingBlocks:
  174. canvas := paintbrush.New()
  175. canvas.SetImage(img)
  176. canvas.SetWidth(cols)
  177. canvas.SetHeight(rows)
  178. canvas.Weights = map[rune]float64{
  179. '': .95,
  180. '': .95,
  181. '▁': .9,
  182. '▂': .9,
  183. '▃': .9,
  184. '▄': .9,
  185. '▅': .9,
  186. '▆': .85,
  187. '█': .85,
  188. '▊': .95,
  189. '▋': .95,
  190. '▌': .95,
  191. '▍': .95,
  192. '▎': .95,
  193. '▏': .95,
  194. '●': .95,
  195. '◀': .95,
  196. '▲': .95,
  197. '▶': .95,
  198. '▼': .9,
  199. '○': .8,
  200. '◉': .95,
  201. '◧': .9,
  202. '◨': .9,
  203. '◩': .9,
  204. '◪': .9,
  205. }
  206. canvas.Paint()
  207. return strings.TrimSpace(canvas.GetResult())
  208. case EncodingKitty:
  209. // Build Kitty graphics unicode place holders
  210. var fg color.Color
  211. var extra int
  212. var r, g, b int
  213. hashedID := key.Hash()
  214. id := int(hashedID)
  215. extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
  216. if id <= 255 {
  217. fg = ansi.IndexedColor(b)
  218. } else {
  219. fg = color.RGBA{
  220. R: uint8(r), //nolint:gosec
  221. G: uint8(g), //nolint:gosec
  222. B: uint8(b), //nolint:gosec
  223. A: 0xff,
  224. }
  225. }
  226. fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
  227. var buf bytes.Buffer
  228. for y := range rows {
  229. // As an optimization, we only write the fg color sequence id, and
  230. // column-row data once on the first cell. The terminal will handle
  231. // the rest.
  232. buf.WriteString(fgStyle)
  233. buf.WriteRune(kitty.Placeholder)
  234. buf.WriteRune(kitty.Diacritic(y))
  235. buf.WriteRune(kitty.Diacritic(0))
  236. if extra > 0 {
  237. buf.WriteRune(kitty.Diacritic(extra))
  238. }
  239. for x := 1; x < cols; x++ {
  240. buf.WriteString(fgStyle)
  241. buf.WriteRune(kitty.Placeholder)
  242. }
  243. if y < rows-1 {
  244. buf.WriteByte('\n')
  245. }
  246. }
  247. return buf.String()
  248. default:
  249. return ""
  250. }
  251. }