From 988be3248067b2110fb1b2c9bbc4812bfddad3b0 Mon Sep 17 00:00:00 2001 From: noe Date: Sun, 3 Nov 2024 22:39:52 -0800 Subject: [PATCH] ndi! --- .envrc | 2 + .gitignore | 1 + README.md | 32 ++++++- cmd/ndiconv/main.go | 60 ++++++++++++ flake.lock | 58 ++++++++++++ flake.nix | 15 +++ go.mod | 3 + ndi.go | 219 ++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 32 +++++++ 9 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 .envrc create mode 100644 cmd/ndiconv/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 ndi.go create mode 100644 shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..fb4b158 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +dotenv; +use flake; diff --git a/.gitignore b/.gitignore index 5b90e79..fbe81e9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work.sum # env file .env +.direnv/ diff --git a/README.md b/README.md index 3e8a70f..067e85c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ # ndi -iNDexed Image format - consider itself lucky. \ No newline at end of file +iNDexed Image format - consider itself lucky. + +This is the Go reference for `ndi` v1. + +## Format + +### Header + +magic string: `ndi1` + +image width: uint16 + +LUT/palette size: uint8 (maximum of 255 colors) + +zero pad until 12 bytes + +`ndi1 WWL0 0000` + +### LUT (lookup table) / palette + +3x uint8 in RGB order, per LUT index. + +zero pad: 32 bits + +### Pixels + +a uint8 per pixel, referencing the color within the LUT, zero indexed. + +## Decoding Quirks + +Pixels should modulo their value with LUT size. A pixel referencing index 0xEF when only 10 colors exist in the LUT, `0xEF % 10` is the correct interpretation. diff --git a/cmd/ndiconv/main.go b/cmd/ndiconv/main.go new file mode 100644 index 0000000..f8c50db --- /dev/null +++ b/cmd/ndiconv/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "image" + "image/gif" + "image/jpeg" + "image/png" + "log" + "os" + "path" + + "git.sapphic.engineer/noe/esi" + "git.sapphic.engineer/noe/ndi" +) + +func main() { + convPath := os.Args[1] + outPath := os.Args[2] + + fin, err := os.Open(convPath) + if err != nil { + log.Fatalln(err) + } + + fout, err := os.OpenFile(outPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) + if err != nil { + log.Fatalln(err) + } + + img, _, err := image.Decode(fin) + if err != nil { + log.Fatalln(err) + } + + fin.Close() + + ext := path.Ext(outPath) + switch ext[1:] { + case "ndi": + err = ndi.Encode(fout, img, nil) + case "esi": + err = esi.Encode(fout, img) + case "png": + err = png.Encode(fout, img) + case "jpg": + fallthrough + case "jpeg": + err = jpeg.Encode(fout, img, nil) + case "gif": + err = gif.Encode(fout, img, nil) + default: + log.Fatalf("extension %s unknown", ext) + } + + if err != nil { + log.Fatalln(err) + } + + fout.Close() +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..16a265d --- /dev/null +++ b/flake.lock @@ -0,0 +1,58 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1730504689, + "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "506278e768c2a08bec68eb62932193e341f55c90", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1730531603, + "narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1730504152, + "narHash": "sha256-lXvH/vOfb4aGYyvFmZK/HlsNsr/0CVWlwYvo2rxJk3s=", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b0b6c25 --- /dev/null +++ b/flake.nix @@ -0,0 +1,15 @@ +{ + description = "saerro"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + + outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ "x86_64-linux" "aarch64-linux" ]; + perSystem = { config, self', pkgs, lib, system, ... }: { + devShells.default = import ./shell.nix { inherit pkgs; }; + }; + }; +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..20ceaf3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sapphic.engineer/noe/ndi + +go 1.23.2 diff --git a/ndi.go b/ndi.go new file mode 100644 index 0000000..8a7d14b --- /dev/null +++ b/ndi.go @@ -0,0 +1,219 @@ +package ndi + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "image" + "image/color" + "image/color/palette" + "io" + "log" +) + +var ( + ErrNotNDI = errors.New("ndi: not an ndi file") + ErrUnexpectedData = errors.New("ndi: unexpected data") + + NDI1MagicBytes = []byte("ndi1") + + Logger = log.New(log.Writer(), "ndi debug: ", 0) +) + +const ( + LUTColorStride = 3 + HeaderStride = 12 + LUTPadding = 4 +) + +func init() { + image.RegisterFormat("ndi", string(NDI1MagicBytes), Decode, DecodeConfig) +} + +func Decode(r io.Reader) (o image.Image, err error) { + buf := bytes.Buffer{} + _, err = io.Copy(&buf, r) + if err != nil { + return o, fmt.Errorf("ndi: decode copy failed") + } + + cfgBuf := bytes.NewBuffer(buf.Bytes()) + cfg, err := DecodeConfig(cfgBuf) + if err != nil { + return o, fmt.Errorf("ndi: decode config failed: %w", err) + } + + cfgBuf = bytes.NewBuffer(buf.Bytes()) + palette, err := DecodePalette(cfgBuf) + if err != nil { + return o, fmt.Errorf("ndi: decode palette failed: %w", err) + } + + bounds := image.Rect(0, 0, cfg.Width, cfg.Height) + img := image.NewPaletted(bounds, palette) + + // skip until end of LUT + buf.Next(HeaderStride + (len(palette) * LUTColorStride) + LUTPadding) + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + index, err := buf.ReadByte() + if err != nil { + // skip because we don't mind bad pixels + continue + } + + img.SetColorIndex(x, y, uint8(index)%uint8(len(palette))) + } + } + + return img, nil +} + +func DecodeConfig(r io.Reader) (cfg image.Config, err error) { + imageBuf := bytes.Buffer{} + readBytes, err := io.Copy(&imageBuf, r) + if err != nil { + return cfg, fmt.Errorf("ndi: decode config copy failed") + } + + header := imageBuf.Next(HeaderStride) + + magicBytes := header[0:4] + widthBytes := header[4:6] + lutSizeByte := header[6] + + if !bytes.Equal(magicBytes, NDI1MagicBytes) { + return cfg, ErrNotNDI + } + + width := binary.BigEndian.Uint16(widthBytes) + lutByteWidth := int(lutSizeByte) * LUTColorStride + + imagePixelCount := readBytes - ((int64(lutByteWidth) + LUTPadding) + HeaderStride) + height := imagePixelCount / int64(width) + + cfg.ColorModel = color.RGBAModel + cfg.Width = int(width) + cfg.Height = int(height) + + return cfg, nil +} + +func DecodePalette(r io.Reader) (p color.Palette, err error) { + imageBuf := bytes.Buffer{} + _, err = io.Copy(&imageBuf, r) + if err != nil { + return + } + + header := imageBuf.Next(HeaderStride) + lutCount := int(header[6]) + + tableSize := lutCount * LUTColorStride + tableBytes := imageBuf.Next(tableSize) + + p = make(color.Palette, lutCount) + + for index := 0; index < lutCount; index++ { + baseIndex := index * LUTColorStride + + p[index] = color.RGBA{ + R: tableBytes[baseIndex+0], + G: tableBytes[baseIndex+1], + B: tableBytes[baseIndex+2], + A: 0xFF, + } + } + + return +} + +func Encode(w io.Writer, img image.Image, cfg *Options) error { + if cfg == nil { + cfg = &Options{ + Palette: palette.WebSafe, + } + } + + _, err := w.Write(NDI1MagicBytes) + if err != nil { + return err + } + + bounds := img.Bounds() + width := bounds.Max.X - bounds.Min.X + widthBytes := make([]byte, 2) + binary.BigEndian.PutUint16(widthBytes, uint16(width)) + + Logger.Println("width ", width) + Logger.Println("width bytes ", widthBytes) + + _, err = w.Write(widthBytes) + if err != nil { + return err + } + + err = encodeLUT(w, cfg) + if err != nil { + return err + } + + w.Write([]byte("AWAW")) + + err = encodePixels(w, img, cfg) + if err != nil { + return err + } + + return nil +} + +func encodeLUT(w io.Writer, cfg *Options) error { + if len(cfg.Palette) == 0 { + cfg.Palette = palette.WebSafe + } + + _, err := w.Write([]byte{byte(len(cfg.Palette))}) + if err != nil { + return err + } + + w.Write([]byte("AWAWA")) + + for _, color := range cfg.Palette { + r, g, b, _ := color.RGBA() + _, err = w.Write([]byte{ + byte(r / 256), + byte(g / 256), + byte(b / 256), + }) + if err != nil { + return err + } + } + + return nil +} + +func encodePixels(w io.Writer, img image.Image, cfg *Options) error { + bounds := img.Bounds() + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + color := img.At(x, y) + index := uint8(cfg.Palette.Index(color)) + _, err := w.Write([]byte{index}) + if err != nil { + return err + } + } + } + + return nil +} + +type Options struct { + Palette color.Palette +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..456a9b5 --- /dev/null +++ b/shell.nix @@ -0,0 +1,32 @@ +{ pkgs ? import {} }: let + podmanSetupScript = let + registriesConf = pkgs.writeText "registries.conf" '' + [registries.search] + registries = ['docker.io'] + [registries.block] + registries = [] + ''; + in pkgs.writeScript "podman-setup" '' + #!${pkgs.runtimeShell} + # Dont overwrite customised configuration + if ! test -f ~/.config/containers/policy.json; then + install -Dm555 ${pkgs.skopeo.src}/default-policy.json ~/.config/containers/policy.json + fi + if ! test -f ~/.config/containers/registries.conf; then + install -Dm555 ${registriesConf} ~/.config/containers/registries.conf + fi + ''; + + # Provides a fake "docker" binary mapping to podman + dockerCompat = pkgs.runCommandNoCC "docker-podman-compat" {} '' + mkdir -p $out/bin + ln -s ${pkgs.podman}/bin/podman $out/bin/docker + ''; +in pkgs.mkShell { + buildInputs = with pkgs; [ + go + just + docker-compose + sqlite + ]; +}