Files

467 lines
13 KiB
Go

package art
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"io"
"sort"
"strconv"
)
const FormatVersion = 1
const SourceExtension = ".aeart"
type imageRecord struct {
ID ID `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
File string `json:"file"`
Size int `json:"size"`
SHA256 string `json:"sha256"`
}
type manifest struct {
Version int `json:"version"`
Document Info `json:"document"`
Next uint64 `json:"next"`
Images []imageRecord `json:"images"`
RequiredExtensions []string `json:"requiredExtensions,omitempty"`
}
func pixelBytes(p *pixels) []byte {
keys := make([]tileKey, 0, len(p.Tiles))
for k := range p.Tiles {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i].Y < keys[j].Y || (keys[i].Y == keys[j].Y && keys[i].X < keys[j].X) })
data := make([]byte, 4+len(keys)*(8+TileSize*TileSize*4))
binary.LittleEndian.PutUint32(data, uint32(len(keys)))
off := 4
for _, k := range keys {
binary.LittleEndian.PutUint32(data[off:], uint32(k.X))
binary.LittleEndian.PutUint32(data[off+4:], uint32(k.Y))
off += 8
for _, v := range p.Tiles[k] {
binary.LittleEndian.PutUint32(data[off:], v)
off += 4
}
}
return data
}
// Encode writes a versioned, bounded ZIP container. Only CPU data is saved;
// tiles and cels have distinct identities, so linked cels survive reload.
func (d *Document) Encode() ([]byte, error) {
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil, ErrClosed
}
s, id, rev, next, lim := d.current, d.id, d.revision, d.next, d.limits
d.mu.Unlock()
m := manifest{Version: FormatVersion, Document: info(id, rev, s), Next: next}
ids := make([]ID, 0, len(s.Images))
for id := range s.Images {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var out bytes.Buffer
z := zip.NewWriter(&out)
for _, id := range ids {
p := s.Images[id]
b := pixelBytes(p)
hash := sha256.Sum256(b)
name := "images/" + string(id) + ".bin"
m.Images = append(m.Images, imageRecord{id, p.Width, p.Height, name, len(b), hex.EncodeToString(hash[:])})
f, err := z.Create(name)
if err != nil {
return nil, err
}
if _, err = f.Write(b); err != nil {
return nil, err
}
if int64(out.Len()) > lim.MaxFileBytes {
return nil, ErrLimit
}
}
b, err := json.Marshal(m)
if err != nil {
return nil, err
}
f, err := z.Create("manifest.json")
if err != nil {
return nil, err
}
if _, err = f.Write(b); err != nil {
return nil, err
}
if err = z.Close(); err != nil {
return nil, err
}
if int64(out.Len()) > lim.MaxFileBytes {
return nil, ErrLimit
}
return out.Bytes(), nil
}
func readZip(f *zip.File, limit int64) ([]byte, error) {
if f.UncompressedSize64 > uint64(limit) || f.Mode()&^0777 != 0 {
return nil, fmt.Errorf("art: unsupported or oversized archive entry")
}
r, err := f.Open()
if err != nil {
return nil, err
}
defer r.Close()
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, ErrLimit
}
return data, nil
}
func Decode(data []byte, limits Limits) (*Document, error) {
l := limitsOrDefault(limits)
if int64(len(data)) > l.MaxFileBytes {
return nil, ErrLimit
}
z, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("art: invalid source: %w", err)
}
if len(z.File) < 1 || len(z.File) > l.MaxCels+1 {
return nil, ErrLimit
}
files := make(map[string]*zip.File)
var total uint64
for _, f := range z.File {
if files[f.Name] != nil {
return nil, fmt.Errorf("art: duplicate archive entry")
}
files[f.Name] = f
budget := uint64(l.MaxPixelBytes) + (8 << 20)
if f.UncompressedSize64 > budget-total {
return nil, ErrLimit
}
total += f.UncompressedSize64
}
mf := files["manifest.json"]
if mf == nil {
return nil, fmt.Errorf("art: manifest is missing")
}
raw, err := readZip(mf, 8<<20)
if err != nil {
return nil, err
}
var m manifest
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err = dec.Decode(&m); err != nil {
return nil, fmt.Errorf("art: invalid manifest: %w", err)
}
var trailing any
if dec.Decode(&trailing) != io.EOF {
return nil, fmt.Errorf("art: trailing manifest data")
}
if m.Version != FormatVersion {
return nil, fmt.Errorf("art: unsupported version %d", m.Version)
}
if len(m.RequiredExtensions) != 0 {
return nil, fmt.Errorf("art: unsupported required extensions")
}
if len(m.Document.ID) != 32 {
return nil, fmt.Errorf("art: invalid document ID")
}
if _, err = hex.DecodeString(m.Document.ID); err != nil {
return nil, fmt.Errorf("art: invalid document ID")
}
i := m.Document
c := i.Canvas
if c.Width < 1 || c.Height < 1 || c.Width > l.MaxDimension || c.Height > l.MaxDimension || int64(c.Width)*int64(c.Height) > int64(l.MaxCanvasPixels) {
return nil, ErrLimit
}
if len(m.Images) > l.MaxCels || len(m.Images)+1 != len(files) {
return nil, fmt.Errorf("art: unexpected archive entries")
}
s := &state{Canvas: c, Palette: i.Palette, Layers: i.Layers, Frames: i.Frames, Cels: i.Cels, Tags: i.Tags, Slices: i.Slices, Properties: i.Properties, Images: make(map[ID]*pixels)}
if len(s.Palette) < 1 || len(s.Palette) > 256 {
return nil, fmt.Errorf("art: invalid palette")
}
// Restore independent COW blocks by content while preserving image IDs. A
// duplicate image and a linked image therefore remain semantically distinct.
tiles := make(map[[32]byte]*tile)
var expanded int64
for _, rec := range m.Images {
if !validID(rec.ID) || s.Images[rec.ID] != nil || rec.File != "images/"+string(rec.ID)+".bin" {
return nil, fmt.Errorf("art: invalid image record")
}
f := files[rec.File]
if f == nil || rec.Size < 4 || int64(rec.Size) > l.MaxPixelBytes || uint64(rec.Size) != f.UncompressedSize64 {
return nil, fmt.Errorf("art: missing or oversized image")
}
if rec.Width < 1 || rec.Height < 1 || rec.Width > l.MaxDimension || rec.Height > l.MaxDimension || int64(rec.Width)*int64(rec.Height) > int64(l.MaxCanvasPixels) {
return nil, ErrLimit
}
b, err := readZip(f, int64(rec.Size))
if err != nil {
return nil, err
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != rec.SHA256 {
return nil, fmt.Errorf("art: image checksum mismatch")
}
count := int64(binary.LittleEndian.Uint32(b))
if count > (int64(rec.Width+TileSize-1)/TileSize)*(int64(rec.Height+TileSize-1)/TileSize) || 4+count*(8+TileSize*TileSize*4) != int64(len(b)) {
return nil, fmt.Errorf("art: invalid tile length")
}
imageChannel := Albedo
for _, cel := range s.Cels {
if cel.Image == rec.ID {
imageChannel = cel.Channel
break
}
}
imageMode := s.forChannel(imageChannel).Canvas.Mode
p := newPixels(rec.Width, rec.Height)
off := 4
for n := int64(0); n < count; n++ {
x, y := binary.LittleEndian.Uint32(b[off:]), binary.LittleEndian.Uint32(b[off+4:])
off += 8
if uint64(x)*TileSize >= uint64(rec.Width) || uint64(y)*TileSize >= uint64(rec.Height) {
return nil, fmt.Errorf("art: tile outside image")
}
key := tileKey{int(x), int(y)}
if p.Tiles[key] != nil {
return nil, fmt.Errorf("art: duplicate tile")
}
payload := b[off : off+TileSize*TileSize*4]
hash := sha256.Sum256(payload)
for k := 0; k < TileSize*TileSize; k++ {
v := binary.LittleEndian.Uint32(payload[k*4:])
if imageMode == Indexed && uint64(v) >= uint64(len(s.Palette)) {
return nil, fmt.Errorf("art: invalid pixel index")
}
if imageMode == Grayscale {
color := unpack(v)
if color.R != color.G || color.G != color.B {
return nil, fmt.Errorf("art: invalid grayscale pixel")
}
}
}
t := tiles[hash]
if t == nil {
t = new(tile)
for k := range t {
v := binary.LittleEndian.Uint32(payload[k*4:])
if imageMode == Indexed && uint64(v) >= uint64(len(s.Palette)) {
return nil, fmt.Errorf("art: invalid pixel index")
}
if imageMode == Grayscale {
color := unpack(v)
if color.R != color.G || color.G != color.B {
return nil, fmt.Errorf("art: invalid grayscale pixel")
}
}
t[k] = v
}
tiles[hash] = t
expanded += TileSize * TileSize * 4
if expanded > l.MaxPixelBytes {
return nil, ErrLimit
}
}
p.Tiles[key] = t
off += TileSize * TileSize * 4
}
s.Images[rec.ID] = p
}
if err = s.validate(l); err != nil {
return nil, err
}
var high uint64
check := func(id ID) error {
if !validID(id) {
return fmt.Errorf("art: invalid entity ID")
}
n, _ := strconv.ParseUint(string(id), 16, 64)
if n > high {
high = n
}
return nil
}
for _, x := range s.Layers {
if err = check(x.ID); err != nil {
return nil, err
}
}
for _, x := range s.Frames {
if err = check(x.ID); err != nil {
return nil, err
}
}
for _, x := range s.Cels {
if err = check(x.ID); err != nil {
return nil, err
}
}
for _, x := range s.Tags {
if err = check(x.ID); err != nil {
return nil, err
}
}
for _, x := range s.Slices {
if err = check(x.ID); err != nil {
return nil, err
}
}
for id := range s.Images {
if err = check(id); err != nil {
return nil, err
}
}
if m.Next < high || m.Next > 1<<52 || i.Revision > 1<<52 {
return nil, fmt.Errorf("art: invalid identity counter")
}
return &Document{id: i.ID, revision: max(1, i.Revision), next: m.Next, current: s, limits: l}, nil
}
func validID(id ID) bool {
if len(id) != 16 {
return false
}
n, err := strconv.ParseUint(string(id), 16, 64)
return err == nil && n > 0
}
// CheckPNGPrecision refuses source channels that the eight-bit Art model
// cannot represent. Callers can offer a separate, explicitly lossy conversion
// rather than treating this as a lossless open or source migration.
func CheckPNGPrecision(cfg image.Config) error {
switch cfg.ColorModel {
case color.Gray16Model, color.RGBA64Model, color.NRGBA64Model:
return fmt.Errorf("art: 16-bit PNG channels cannot be preserved in an 8-bit Art Document")
}
return nil
}
func ImportPNG(data []byte, limits Limits) (*Document, error) {
l := limitsOrDefault(limits)
if int64(len(data)) > l.MaxFileBytes {
return nil, ErrLimit
}
cfg, err := png.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, err
}
if err := CheckPNGPrecision(cfg); err != nil {
return nil, err
}
if cfg.Width < 1 || cfg.Height < 1 || cfg.Width > l.MaxDimension || cfg.Height > l.MaxDimension || int64(cfg.Width)*int64(cfg.Height) > int64(l.MaxCanvasPixels) {
return nil, ErrLimit
}
im, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
d, err := New(cfg.Width, cfg.Height, RGBA, l)
if err != nil {
return nil, err
}
e, err := d.Begin(d.Revision(), "Import PNG")
if err != nil {
return nil, err
}
cel := e.work.Cels[0]
for y := 0; y < cfg.Height; y++ {
for x := 0; x < cfg.Width; x++ {
v := color.NRGBAModel.Convert(im.At(x, y)).(color.NRGBA)
if err = e.raw(cel.Image, x, y, Color{v.R, v.G, v.B, v.A}.packed()); err != nil {
return nil, err
}
}
}
change, err := e.Prepare()
if err != nil {
return nil, err
}
if change != nil {
if err = change.Apply(); err != nil {
return nil, err
}
}
return d, nil
}
func (d *Document) PNG(frame ID, channel Channel) ([]byte, error) {
im, err := d.Render(frame, RenderOptions{Channel: channel})
if err != nil {
return nil, err
}
var b bytes.Buffer
if err = png.Encode(&b, im); err != nil {
return nil, err
}
return b.Bytes(), nil
}
type SheetFrame struct {
ID ID `json:"id"`
Rect image.Rectangle `json:"rect"`
DurationMS int `json:"durationMS"`
}
type Sheet struct {
PNG []byte `json:"-"`
Width int `json:"width"`
Height int `json:"height"`
Frames []SheetFrame `json:"frames"`
Tags []Tag `json:"tags"`
Slices []Slice `json:"slices"`
}
// ExportSheet exports pixels and stable frame IDs. Runtime AssetIDs remain the
// importer's responsibility: this core never fabricates a second AssetID space.
func (d *Document) ExportSheet(columns int, channel Channel) (Sheet, error) {
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return Sheet{}, ErrClosed
}
s, l := d.current, d.limits
d.mu.Unlock()
n := len(s.Frames)
if columns < 1 || columns > n {
columns = n
}
rows := (n + columns - 1) / columns
w64, h64 := int64(s.Canvas.Width)*int64(columns), int64(s.Canvas.Height)*int64(rows)
if w64 > 16384 || h64 > 16384 || w64*h64*4 > l.MaxPixelBytes {
return Sheet{}, ErrLimit
}
w, h := int(w64), int(h64)
out := image.NewNRGBA(image.Rect(0, 0, w, h))
sheet := Sheet{Width: w, Height: h, Tags: append([]Tag(nil), s.Tags...), Slices: append([]Slice(nil), s.Slices...)}
for i, f := range s.Frames {
im := image.NewNRGBA(image.Rect(0, 0, s.Canvas.Width, s.Canvas.Height))
if err := renderRegion(s, f.ID, im, im.Bounds(), options(RenderOptions{Channel: channel})); err != nil {
return Sheet{}, err
}
x, y := i%columns*s.Canvas.Width, i/columns*s.Canvas.Height
for row := 0; row < s.Canvas.Height; row++ {
copy(out.Pix[out.PixOffset(x, y+row):out.PixOffset(x, y+row)+s.Canvas.Width*4], im.Pix[row*im.Stride:row*im.Stride+s.Canvas.Width*4])
}
sheet.Frames = append(sheet.Frames, SheetFrame{f.ID, image.Rect(x, y, x+s.Canvas.Width, y+s.Canvas.Height), f.DurationMS})
}
var b bytes.Buffer
if err := png.Encode(&b, out); err != nil {
return Sheet{}, err
}
sheet.PNG = b.Bytes()
return sheet, nil
}