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 file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.direnv/
|
||||||
|
|
32
README.md
32
README.md
|
@ -1,3 +1,33 @@
|
||||||
# ndi
|
# 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