Code to convert image at path into cached RGBA data

This commit is contained in:
Kovid Goyal
2024-07-22 21:38:13 +05:30
parent 1b6f74da65
commit fb20c4acb6
4 changed files with 283 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ import (
"kitty/tools/cmd/show_error"
"kitty/tools/cmd/update_self"
"kitty/tools/tui"
"kitty/tools/utils/images"
)
var _ = fmt.Print
@@ -103,6 +104,8 @@ func KittyToolEntryPoints(root *cli.Command) {
return confirm_and_run_shebang(args)
},
})
// __render_image__
images.RenderEntryPoint(root)
// __generate_man_pages__
root.AddSubCommand(&cli.Command{
Name: "__generate_man_pages__",

View File

@@ -0,0 +1,174 @@
package images
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"kitty/tools/cli"
"kitty/tools/utils"
)
var _ = fmt.Print
func convert_and_save_as_rgba_data(input_path, output_path string, perm os.FileMode) (err error) {
f, err := os.Open(input_path)
if err != nil {
return err
}
defer f.Close()
image_data, err := OpenNativeImageFromReader(f)
if err != nil {
return err
}
if len(image_data.Frames) == 0 {
return fmt.Errorf("Image at %s has no frames", input_path)
}
img := image_data.Frames[0].Img
var final_img *image.NRGBA
switch img.(type) {
case *image.NRGBA:
final_img = img.(*image.NRGBA)
default:
b := img.Bounds()
final_img = image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
ctx := Context{}
ctx.PasteCenter(final_img, img, nil)
}
b := final_img.Bounds()
header := make([]byte, 8)
var width = utils.Abs(b.Dx())
var height = utils.Abs(b.Dy())
binary.LittleEndian.PutUint32(header, uint32(width))
binary.LittleEndian.PutUint32(header[4:], uint32(height))
readers := []io.Reader{bytes.NewReader(header)}
stride := 4 * width
if final_img.Stride == stride {
readers = append(readers, bytes.NewReader(final_img.Pix))
} else {
p := final_img.Pix
for y := 0; y < b.Dy(); y++ {
readers = append(readers, bytes.NewReader(p[:min(stride, len(p))]))
p = p[final_img.Stride:]
}
}
return utils.AtomicUpdateFile(output_path, io.MultiReader(readers...), perm)
}
var now_implementation = time.Now
var chmtime_after_creation = false
func prune_cache(cdir string, max_entries int) error {
entries, err := os.ReadDir(cdir)
if err != nil {
return err
}
if len(entries) <= max_entries {
return nil
}
now := now_implementation()
epoch := time.Unix(0, 0)
entries = utils.SortWithKey(entries, func(a fs.DirEntry) time.Duration {
if info, err := a.Info(); err == nil {
return now.Sub(info.ModTime())
}
return now.Sub(epoch)
})
for _, x := range entries[max_entries:] {
if err = os.Remove(filepath.Join(cdir, x.Name())); err != nil {
return err
}
}
return nil
}
func render_image(src_path, cdir string, max_cache_entries int) (output_path string, err error) {
src_path, err = filepath.EvalSymlinks(src_path)
if err != nil {
return
}
lock_file := filepath.Join(cdir, "lock")
lockf, err := os.OpenFile(lock_file, os.O_CREATE|os.O_RDWR, 0600)
defer lockf.Close()
if err != nil {
return
}
if err = utils.LockFileExclusive(lockf); err != nil {
return "", fmt.Errorf("Failed to lock cache file %s with error: %w", lock_file, err)
}
defer func() {
utils.UnlockFile(lockf)
}()
output_path = filepath.Join(cdir, hex.EncodeToString(sha256.New().Sum([]byte(src_path)))) + ".rgba"
needs_update := true
input_info, err := os.Stat(src_path)
if err != nil {
return
}
output_info, err := os.Stat(output_path)
if err == nil {
needs_update = input_info.Size() != output_info.Size() || input_info.ModTime().After(output_info.ModTime())
} else {
if !errors.Is(err, fs.ErrNotExist) {
return
}
}
if needs_update {
if err = convert_and_save_as_rgba_data(src_path, output_path, 0600); err != nil {
return
}
if chmtime_after_creation {
n := now_implementation()
if err = os.Chtimes(output_path, n, n); err != nil {
return
}
}
if err = prune_cache(cdir, max_cache_entries); err != nil {
return
}
} else {
n := now_implementation()
if err = os.Chtimes(output_path, n, n); err != nil {
return
}
}
return
}
func RenderEntryPoint(root *cli.Command) {
root.AddSubCommand(&cli.Command{
Name: "__render_image__",
Hidden: true,
OnlyArgsAllowed: true,
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
if len(args) != 1 {
return 1, fmt.Errorf("Usage: render input_image_path")
}
src_path, err := filepath.EvalSymlinks(args[0])
if err != nil {
return 1, err
}
cdir := utils.CacheDir()
cdir = filepath.Join(cdir, "rgba")
if err = os.MkdirAll(cdir, 0755); err != nil {
return 1, err
}
if output_path, err := render_image(src_path, cdir, 32); err != nil {
return 1, err
} else {
fmt.Println(output_path)
}
return
},
})
}

View File

@@ -0,0 +1,99 @@
package images
import (
"encoding/binary"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
var one_pixel_gray_png = []byte{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0, 58, 126, 155, 85, 0, 0, 0, 10, 73, 68, 65, 84, 120, 1, 99, 176, 5, 0, 0, 63, 0, 62, 18, 174, 200, 16, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130}
func TestRenderCache(t *testing.T) {
chmtime_after_creation = true
epoch := time.Now()
now_implementation = func() time.Time {
epoch = epoch.Add(3 * time.Second)
return epoch
}
defer func() {
chmtime_after_creation = false
now_implementation = time.Now
}()
tmp := t.TempDir()
cdir := filepath.Join(tmp, "cache")
if err := os.Mkdir(cdir, 0777); err != nil {
t.Fatal(err)
}
srcs := make([]string, 0, 5)
outputs := make([]string, 0, 5)
const max_cache_entries = 2
for i := range max_cache_entries * 2 {
name := fmt.Sprintf(`%d.png`, i)
src_path := filepath.Join(tmp, name)
srcs = append(srcs, src_path)
if err := os.WriteFile(src_path, one_pixel_gray_png, 0644); err != nil {
t.Fatal(err)
}
output_path, err := render_image(src_path, cdir, max_cache_entries)
if err != nil {
t.Fatal(err)
}
outputs = append(outputs, output_path)
if entries, err := os.ReadDir(cdir); err != nil {
t.Fatal(err)
} else if len(entries) > max_cache_entries {
t.Fatalf("Too many files in cache dir %d > %d", len(entries), max_cache_entries)
}
}
exists := func(path string) bool {
_, err := os.Stat(path)
return err == nil
}
mtime := func(path string) time.Time {
ans, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return ans.ModTime()
}
for i, x := range outputs[len(outputs)-max_cache_entries:] {
if !exists(x) {
t.Fatalf("The %d cache entry does not exist", max_cache_entries+i)
}
}
o := outputs[len(outputs)-max_cache_entries:]
if mtime(o[0]).After(mtime(o[1])) {
t.Fatalf("The mtimes are not monotonic")
}
s := srcs[len(srcs)-max_cache_entries:]
output_path, err := render_image(s[0], cdir, max_cache_entries)
if err != nil {
t.Fatal(err)
}
if output_path != o[0] {
t.Fatalf("Output path change on rerun")
}
if mtime(o[1]).After(mtime(o[0])) || mtime(o[1]).Equal(mtime(o[0])) {
t.Fatalf("The mtime was not updated")
}
data, err := os.ReadFile(output_path)
if err != nil {
t.Fatal(err)
}
if len(data) != 12 {
t.Fatalf("unexpected data length: %d != %d", len(data), 12)
}
if width, height := binary.LittleEndian.Uint32(data), binary.LittleEndian.Uint32(data[4:]); width != 1 || height != 1 {
t.Fatalf("unexpected dimensions: %dx%d", width, height)
}
if diff := cmp.Diff(data[8:], []byte{61, 61, 61, 255}); diff != "" {
t.Fatalf("unexpected pixel: %s", diff)
}
}

View File

@@ -310,3 +310,10 @@ func FunctionName(a any) string {
}
return ""
}
func Abs[T constraints.Integer](x T) T {
if x < 0 {
return -x
}
return x
}