Files
aego/app/agent/paint.go
T
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

222 lines
7.0 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image"
"image/png"
"io"
"path"
"strings"
"aego/anim"
"aego/asset/pipeline"
"aego/core/ids"
"aego/format/pixelart"
)
func paintCommand(out, replace Arg) *Command {
props := map[string]any{"op": map[string]any{"type": "string", "enum": []string{"pixel", "line", "rect", "fill", "copy"}}}
for _, n := range []string{"x", "y", "x2", "y2", "w", "h", "toX", "toY"} {
props[n] = map[string]any{"type": "integer", "minimum": 0, "maximum": 4096}
}
for _, n := range []string{"flipX", "flipY"} {
props[n] = map[string]any{"type": "boolean"}
}
props["color"] = map[string]any{"type": "string"}
item := map[string]any{"type": "object", "properties": props, "required": []string{"op"}, "additionalProperties": false}
return &Command{Name: "asset.paint", Write: true, Run: (*Session).paint,
Doc: "Edit a source PNG, create a transparent canvas, or paint on a copy of another source PNG. A single reversible batch; returns image pixels. Coordinates are top-left source pixels.",
Args: []Arg{out, {Name: "ops", Type: "array", Items: item, Required: true, Doc: `Ordered objects: {op:"line",x,y,x2,y2,color}, {op:"rect",x,y,w,h,color}, {op:"fill",x,y,color} flood-fills connected pixels, {op:"pixel",x,y,color}, or {op:"copy",x,y,w,h,toX,toY,flipX,flipY}. Color is #rrggbb[aa], transparent, or a palette key.`},
{Name: "width", Type: "integer", Doc: "width of a new blank canvas"}, {Name: "height", Type: "integer", Doc: "height of a new blank canvas"},
{Name: "source", Type: "string", Doc: "optional existing source PNG under assets/ to copy before editing; defaults to the output if it exists"},
{Name: "palette", Type: "string", Doc: "palette as r=#ff0000 b=#0000ff, or use the project palette"},
replace}}
}
func (s *Session) paint(a Args) (*Output, error) {
rel, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
src := rel
if a.Has("source") {
src, err = pngPath(a.Str("source"))
if err != nil {
return nil, err
}
}
var img image.Image
srcBytes, err := s.Pipeline.SourceBytes(src)
if err != nil {
return nil, err
}
if srcBytes != nil {
cfg, err := png.DecodeConfig(bytes.NewReader(srcBytes))
if err != nil {
return nil, err
}
if cfg.Width > 4096 || cfg.Height > 4096 || cfg.Width*cfg.Height > pixelart.MaxPixels {
return nil, errors.New("source PNG exceeds the canvas budget")
}
img, err = png.Decode(bytes.NewReader(srcBytes))
if err != nil {
return nil, err
}
} else if a.Has("source") {
return nil, fmt.Errorf("source PNG does not exist: %s", src)
}
pal, err := pixelart.ParsePalette(a.Str("palette"))
if err != nil {
return nil, err
}
if !a.Has("palette") {
style, _ := s.art()
for i, c := range style.Palette {
if i >= len(paletteKeys) {
break
}
pal[rune(paletteKeys[i])] = c
}
}
w, err := a.Int("width", 0)
if err != nil {
return nil, err
}
h, err := a.Int("height", 0)
if err != nil {
return nil, err
}
raw := a.Str("ops")
if len(raw) > 1<<20 {
return nil, errors.New("drawing operations exceed 1 MiB")
}
dec := json.NewDecoder(strings.NewReader(raw))
dec.DisallowUnknownFields()
var ops []pixelart.Op
if err = dec.Decode(&ops); err != nil {
return nil, err
}
if ops == nil || dec.Decode(new(any)) != io.EOF {
return nil, errors.New("ops must be one JSON array")
}
after, err := pixelart.Paint(img, w, h, pal, ops)
if err != nil {
return nil, err
}
var guards []pipeline.SourceEdit
if src != rel {
guards = append(guards, pipeline.SourceEdit{Path: src, Before: srcBytes, After: srcBytes})
}
return s.drawnFrom(a, rel, after, nil, nil, false, nil, nil, guards, src == rel, srcBytes)
}
type DrawingEdit struct {
Sources []pipeline.SourceEdit
Files []string
Created []string
}
func (s *Session) prepareDrawing(rel string, data []byte, cells []pipeline.SubAsset, flip bool, settings pipeline.Settings, replace bool, view *DrawnView, guards []pipeline.SourceEdit, checkInput bool, input []byte) (*DrawingEdit, error) {
e := &DrawingEdit{Sources: guards, Files: []string{rel}}
old, err := s.Pipeline.SourceBytes(rel)
if err != nil {
return nil, err
}
if checkInput && ((input == nil) != (old == nil) || !bytes.Equal(input, old)) {
return nil, fmt.Errorf("%s changed while drawing", rel)
}
if old != nil && !replace {
return nil, fmt.Errorf("%s exists; pass replace to draw over it", rel)
}
e.Sources = append(e.Sources, pipeline.SourceEdit{Path: rel, Before: old, After: data})
meta, oldMeta, err := s.sourceMeta(rel, old != nil)
if err != nil {
return nil, err
}
view.ID = meta.ID.String()
if old == nil {
e.Created = append(e.Created, view.ID)
}
for k, v := range settings {
meta.Settings[k] = v
}
metaChanged := oldMeta == nil || len(settings) != 0
if cells != nil {
named := map[string]pipeline.SubAsset{}
for _, sub := range meta.Subs {
named[sub.Name] = sub
}
for i, c := range cells {
prior, ok := named[c.Name]
if !ok {
prior = c
prior.ID = ids.NewAssetID()
e.Created = append(e.Created, prior.ID.String())
}
prior.Rect = c.Rect
cells[i] = prior
}
meta.Subs = cells
metaChanged = true
}
bounds := image.Rect(0, 0, view.Size[0], view.Size[1])
for _, v := range meta.Subs {
r := v.Rect
if r[0] < 0 || r[1] < 0 || r[2] <= 0 || r[3] <= 0 || r[0] > bounds.Dx()-r[2] || r[1] > bounds.Dy()-r[3] {
return nil, errors.New("frame is outside the new canvas: " + v.Name)
}
view.Frames = append(view.Frames, FrameView{Name: v.Name, ID: v.ID.String(), Rect: r})
}
afterMeta := oldMeta
if metaChanged {
afterMeta = pipeline.WriteMeta(meta)
}
e.Sources = append(e.Sources, pipeline.SourceEdit{Path: rel + pipeline.MetaSuffix, Before: oldMeta, After: afterMeta})
if flip {
clip := strings.TrimSuffix(rel, path.Ext(rel)) + ".aeanim"
view.Flipbook = clip
before, err := s.Pipeline.SourceBytes(clip)
if err != nil {
return nil, err
}
if before != nil && !replace {
return nil, fmt.Errorf("%s already exists; pass replace to overwrite the flipbook", clip)
}
cm, prev, err := s.sourceMeta(clip, before != nil)
if err != nil {
return nil, err
}
target := prev
if prev == nil {
target = pipeline.WriteMeta(cm)
e.Created = append(e.Created, cm.ID.String())
}
frames := make([]ids.AssetID, len(cells))
for i, c := range cells {
frames[i] = c.ID
}
e.Sources = append(e.Sources, pipeline.SourceEdit{Path: clip, Before: before, After: anim.FlipbookText(frames)}, pipeline.SourceEdit{Path: clip + pipeline.MetaSuffix, Before: prev, After: target})
e.Files = append(e.Files, clip)
}
return e, nil
}
func (s *Session) sourceMeta(rel string, exists bool) (*pipeline.Meta, []byte, error) {
raw, err := s.Pipeline.SourceBytes(rel + pipeline.MetaSuffix)
if err != nil {
return nil, nil, err
}
if (raw != nil) != exists {
return nil, nil, fmt.Errorf("%s: source and sidecar must exist together; refresh the asset index", rel)
}
if exists {
meta, err := pipeline.ParseMeta(raw, rel+pipeline.MetaSuffix)
return meta, raw, err
}
meta, err := s.Pipeline.NewSourceMeta(rel)
return meta, nil, err
}