ndi!
This commit is contained in:
parent
7909fc9588
commit
988be32480
9 changed files with 421 additions and 1 deletions
2
.envrc
Normal file
2
.envrc
Normal file
|
@ -0,0 +1,2 @@
|
|||
dotenv;
|
||||
use flake;
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -25,3 +25,4 @@ go.work.sum
|
|||
# env file
|
||||
.env
|
||||
|
||||
.direnv/
|
||||
|
|
32
README.md
32
README.md
|
@ -1,3 +1,33 @@
|
|||
# ndi
|
||||
|
||||
iNDexed Image format - consider itself lucky.
|
||||
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.
|
||||
|
|
60
cmd/ndiconv/main.go
Normal file
60
cmd/ndiconv/main.go
Normal file
|
@ -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()
|
||||
}
|
58
flake.lock
generated
Normal file
58
flake.lock
generated
Normal file
|
@ -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
|
||||
}
|
15
flake.nix
Normal file
15
flake.nix
Normal file
|
@ -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; };
|
||||
};
|
||||
};
|
||||
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module git.sapphic.engineer/noe/ndi
|
||||
|
||||
go 1.23.2
|
219
ndi.go
Normal file
219
ndi.go
Normal file
|
@ -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
|
||||
}
|
32
shell.nix
Normal file
32
shell.nix
Normal file
|
@ -0,0 +1,32 @@
|
|||
{ pkgs ? import <nixpkgs> {} }: 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
|
||||
];
|
||||
}
|
Loading…
Add table
Reference in a new issue