Files

458 lines
16 KiB
Go

package artsource
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"path"
"slices"
"strconv"
"strings"
"aego/art"
"aego/asset"
texImporter "aego/asset/importers/texture"
"aego/asset/pipeline"
"aego/core/ids"
"aego/format/aetext"
)
// TextureLink binds a PNG's existing region IDs to an editable art source.
// The PNG is never overwritten; its prepared texture is derived from art.
type TextureLink struct {
Source ids.AssetID
SHA256 string
LayoutSHA256 string // Empty for legacy links created before geometry guards.
Frames []TextureLinkFrame
}
type TextureLinkFrame struct {
Asset ids.AssetID
Frame art.ID
}
func TextureLinkOf(meta *pipeline.Meta) (*TextureLink, error) {
if meta == nil {
return nil, fmt.Errorf("artsource: texture metadata is required")
}
v, exists := meta.Settings["art_source"]
if !exists {
if _, ok := meta.Settings["art_frames"]; ok {
return nil, fmt.Errorf("artsource: art_frames has no art_source")
}
if _, ok := meta.Settings["art_source_layout_sha256"]; ok {
return nil, fmt.Errorf("artsource: art_source_layout_sha256 has no art_source")
}
return nil, nil
}
if v.Kind != aetext.VAsset {
return nil, fmt.Errorf("artsource: art_source must be an AssetRef")
}
id, err := ids.ParseAssetID(v.Str)
if err != nil || id.IsZero() || id == meta.ID {
return nil, fmt.Errorf("artsource: invalid art_source identity")
}
hash := meta.Settings.String("art_source_sha256", "")
decoded, err := hex.DecodeString(hash)
if err != nil || len(decoded) != sha256.Size {
return nil, fmt.Errorf("artsource: invalid original PNG hash")
}
value, ok := meta.Settings["art_frames"]
if !ok || value.Kind != aetext.VList || len(value.List) == 0 || len(value.List) > art.DefaultLimits().MaxFrames {
return nil, fmt.Errorf("artsource: a bounded art_frames mapping is required")
}
link := &TextureLink{Source: id, SHA256: strings.ToLower(hash)}
if value, ok := meta.Settings["art_source_layout_sha256"]; ok {
if value.Kind != aetext.VString {
return nil, fmt.Errorf("artsource: invalid original PNG slicing hash")
}
decoded, err := hex.DecodeString(value.Str)
if err != nil || len(decoded) != sha256.Size || value.Str != strings.ToLower(value.Str) {
return nil, fmt.Errorf("artsource: invalid original PNG slicing hash")
}
link.LayoutSHA256 = value.Str
}
seenAssets := map[ids.AssetID]bool{}
seenFrames := map[art.ID]bool{}
for _, item := range value.List {
if item.Kind != aetext.VStruct || len(item.Obj) != 2 {
return nil, fmt.Errorf("artsource: invalid art_frames entry")
}
var frame TextureLinkFrame
for _, field := range item.Obj {
switch field.Name {
case "asset":
if field.Value.Kind != aetext.VAsset || !frame.Asset.IsZero() {
return nil, fmt.Errorf("artsource: invalid art_frames AssetRef")
}
frame.Asset, err = ids.ParseAssetID(field.Value.Str)
if err != nil || frame.Asset.IsZero() {
return nil, fmt.Errorf("artsource: invalid art_frames AssetRef")
}
case "frame":
if field.Value.Kind != aetext.VString || frame.Frame != "" {
return nil, fmt.Errorf("artsource: invalid art frame identity")
}
frame.Frame = art.ID(field.Value.Str)
counter, err := strconv.ParseUint(string(frame.Frame), 16, 64)
if err != nil || len(frame.Frame) != 16 || counter == 0 {
return nil, fmt.Errorf("artsource: invalid art frame identity")
}
default:
return nil, fmt.Errorf("artsource: unknown art_frames field %q", field.Name)
}
}
if frame.Asset.IsZero() || frame.Frame == "" || seenAssets[frame.Asset] || seenFrames[frame.Frame] {
return nil, fmt.Errorf("artsource: incomplete or duplicate art_frames mapping")
}
// Each original source region has its own authoring frame ID. Linked
// cels may share their image, but reusing a frame ID here would silently
// replace two distinct original regions with the same rendered frame.
seenAssets[frame.Asset], seenFrames[frame.Frame] = true, true
link.Frames = append(link.Frames, frame)
}
if link.LayoutSHA256 != "" && link.LayoutSHA256 != textureLayoutSHA256(meta, link.Frames) {
return nil, fmt.Errorf("artsource: PNG slicing changed or frame mapping changed after linking; resolve the mapping before importing")
}
return link, nil
}
// textureLayoutSHA256 protects both the original asset-to-rectangle and
// asset-to-authoring-frame correspondences, not cosmetic names or pivots.
// PNG bytes (and whole-image dimensions) are protected separately. Sorting
// by ID permits reordering metadata without changing the mapping. This is a
// content check, not an atomic CAS or proof against replacing the hash itself.
func textureLayoutSHA256(meta *pipeline.Meta, links []TextureLinkFrame) string {
frames := slices.Clone(meta.Subs)
slices.SortFunc(frames, func(a, b pipeline.SubAsset) int {
return strings.Compare(a.ID.String(), b.ID.String())
})
mapping := slices.Clone(links)
slices.SortFunc(mapping, func(a, b TextureLinkFrame) int {
return strings.Compare(a.Asset.String(), b.Asset.String())
})
h := sha256.New()
fmt.Fprintf(h, "aego-linked-texture-layout-v1\n%s\n%d\n", meta.ID.String(), len(frames))
for _, frame := range frames {
fmt.Fprintf(h, "%s %d %d %d %d\n", frame.ID.String(), frame.Rect[0], frame.Rect[1], frame.Rect[2], frame.Rect[3])
}
fmt.Fprintf(h, "%d\n", len(mapping))
for _, frame := range mapping {
fmt.Fprintf(h, "%s %s\n", frame.Asset.String(), frame.Frame)
}
return hex.EncodeToString(h.Sum(nil))
}
func SetTextureLink(meta *pipeline.Meta, source ids.AssetID, original []byte, frames []TextureLinkFrame) error {
if meta == nil || meta.Settings == nil {
return fmt.Errorf("artsource: texture metadata is required")
}
hash := sha256.Sum256(original)
items := make([]aetext.Value, 0, len(frames))
for _, f := range frames {
items = append(items, aetext.Struct(
aetext.Field{Name: "asset", Value: aetext.AssetRef(f.Asset.String())},
aetext.Field{Name: "frame", Value: aetext.String(string(f.Frame))},
))
}
updated := *meta
updated.Settings = meta.Settings.Clone()
updated.Settings["art_source"] = aetext.AssetRef(source.String())
updated.Settings["art_source_sha256"] = aetext.String(hex.EncodeToString(hash[:]))
updated.Settings["art_source_layout_sha256"] = aetext.String(textureLayoutSHA256(&updated, frames))
updated.Settings["art_frames"] = aetext.List(items...)
if _, err := TextureLinkOf(&updated); err != nil {
return err
}
meta.Settings = updated.Settings
return nil
}
// LinkedTextureImporter prepares linked art through the existing PNG importer;
// unlinked images keep its original behavior.
type LinkedTextureImporter struct{ *texImporter.Importer }
func NewTexture() *LinkedTextureImporter { return &LinkedTextureImporter{texImporter.New()} }
func (*LinkedTextureImporter) Version() int { return texImporter.Version + 1 }
type linkedTextureSource struct {
link *TextureLink
document *art.Document
meta *pipeline.Meta
name string
encoded []byte
metaData []byte
}
func readLinkedTexture(ctx *pipeline.ImportContext) (*linkedTextureSource, error) {
link, err := TextureLinkOf(ctx.Meta)
if err != nil || link == nil {
return nil, err
}
hash := sha256.Sum256(ctx.Source)
if hex.EncodeToString(hash[:]) != link.SHA256 {
return nil, fmt.Errorf("artsource: original PNG %s changed outside Asset Studio; resolve the source conflict before importing", ctx.Path)
}
if ctx.SourcePath == nil || ctx.Read == nil {
return nil, fmt.Errorf("artsource: linked source resolver is unavailable")
}
name, ok := ctx.SourcePath(link.Source)
if !ok || strings.Contains(name, pipeline.SubSep) || !strings.EqualFold(path.Ext(name), Extension) {
return nil, fmt.Errorf("artsource: linked Art Document is unavailable")
}
encoded, err := ctx.Read(name)
if err != nil {
return nil, err
}
metaData, err := ctx.Read(name + pipeline.MetaSuffix)
if err != nil {
return nil, err
}
meta, err := pipeline.ParseMeta(metaData, name+pipeline.MetaSuffix)
if err != nil {
return nil, err
}
if meta.ID != link.Source || meta.Importer != "artsource" {
return nil, fmt.Errorf("artsource: linked Art Document metadata changed identity")
}
d, err := art.Decode(encoded, art.DefaultLimits())
if err != nil {
return nil, fmt.Errorf("artsource: cannot read linked source %s: %w", name, err)
}
return &linkedTextureSource{link, d, meta, name, encoded, metaData}, nil
}
func (i *LinkedTextureImporter) CacheSalt(ctx *pipeline.ImportContext) ([]byte, error) {
base, err := i.Importer.CacheSalt(ctx)
if err != nil {
return nil, err
}
linked, err := readLinkedTexture(ctx)
if err != nil || linked == nil {
return base, err
}
defer linked.document.Close()
h := sha256.New()
h.Write(base)
h.Write([]byte(linked.name))
h.Write(linked.encoded)
h.Write(linked.metaData)
return h.Sum(nil), nil
}
func (i *LinkedTextureImporter) Import(ctx *pipeline.ImportContext) (*pipeline.Result, error) {
linked, err := readLinkedTexture(ctx)
if err != nil {
return nil, err
}
if linked == nil {
return i.Importer.Import(ctx)
}
defer linked.document.Close()
pixels, err := decodeLinkedPNG(ctx.Source)
if err != nil {
return nil, err
}
info := linked.document.Info()
frames := textureSourceFrames(ctx.Meta, pixels.Bounds())
if len(frames) != len(linked.link.Frames) {
return nil, fmt.Errorf("artsource: PNG slicing changed; resolve the source/frame mapping before importing")
}
byAsset := map[ids.AssetID]art.ID{}
for _, f := range linked.link.Frames {
byAsset[f.Asset] = f.Frame
}
for _, f := range frames {
frame := byAsset[f.ID]
if frame == "" {
return nil, fmt.Errorf("artsource: original frame %s has no authoring frame", f.Name)
}
r := sourceFrameRect(f)
if r.Empty() || !r.In(pixels.Bounds()) || r.Dx() > info.Canvas.Width || r.Dy() > info.Canvas.Height {
return nil, fmt.Errorf("artsource: original frame %s no longer fits its art canvas", f.Name)
}
framePixels, err := linked.document.Render(frame, art.RenderOptions{Channel: art.Albedo})
if err != nil {
return nil, fmt.Errorf("artsource: linked frame %s: %w", f.Name, err)
}
// Never silently crop fresh pixels outside an original source region.
for y := 0; y < framePixels.Bounds().Dy(); y++ {
for x := 0; x < framePixels.Bounds().Dx(); x++ {
if (x >= r.Dx() || y >= r.Dy()) && framePixels.NRGBAAt(x, y).A != 0 {
return nil, fmt.Errorf("artsource: frame %s has pixels outside its original source bounds", f.Name)
}
}
}
draw.Draw(pixels, r, framePixels, image.Point{}, draw.Src)
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, pixels); err != nil {
return nil, err
}
local := *ctx
local.Source = encoded.Bytes()
// Preserve the exact legacy fractional pivot unless the author explicitly
// moves the frame pivot; never rewrite the PNG metadata here.
local.Meta, err = pipeline.ParseMeta(pipeline.WriteMeta(ctx.Meta), ctx.Path+pipeline.MetaSuffix)
if err != nil {
return nil, err
}
for _, original := range frames {
frame := byAsset[original.ID]
rect := sourceFrameRect(original)
pivot := original.EffectivePivot()
baseline := normalizedPivotToPixels(pivot, rect.Dx(), rect.Dy())
authored, hasPivot := info.FramePivot(frame)
if !hasPivot || authored == baseline {
continue
}
pivot = pixelPivotToNormalized(authored, rect.Dx(), rect.Dy())
if original.ID == local.Meta.ID && len(local.Meta.Subs) == 0 {
local.Meta.Pivot, local.Meta.HasPivot = pivot, true
}
for n := range local.Meta.Subs {
if local.Meta.Subs[n].ID == original.ID {
local.Meta.Subs[n].Pivot, local.Meta.Subs[n].HasPivot = pivot, true
}
}
}
result, err := i.Importer.Import(&local)
if err != nil || result == nil || result.Error != nil {
return result, err
}
result.Deps = appendUniqueAsset(result.Deps, linked.link.Source)
channels := map[art.Channel]bool{}
for _, c := range info.Cels {
channels[c.Channel] = true
}
bind := func(frame art.ID, region *asset.RegionArtifact, deps *[]ids.AssetID) {
if frame == "" {
return
}
if channels[art.Normal] {
region.Normal = sourceID(linked.meta, linked.link.Source, FrameName(frame, art.Normal))
*deps = appendUniqueAsset(*deps, region.Normal)
}
if channels[art.Emission] {
region.Emission = sourceID(linked.meta, linked.link.Source, FrameName(frame, art.Emission))
*deps = appendUniqueAsset(*deps, region.Emission)
}
}
if frame := byAsset[ctx.ID]; frame != "" {
if result.Group != "" {
r := &asset.RegionArtifact{Normal: result.RawNormal, Emission: result.RawEmission}
bind(frame, r, &result.Deps)
result.RawNormal, result.RawEmission = r.Normal, r.Emission
}
if err := bindRegionArtifacts(result.Artifacts, frame, &result.Deps, bind); err != nil {
return nil, err
}
}
for n := range result.Subs {
sub := &result.Subs[n]
if err := bindRegionArtifacts(sub.Artifacts, byAsset[sub.ID], &sub.Deps, bind); err != nil {
return nil, err
}
}
return result, nil
}
func bindRegionArtifacts(artifacts []pipeline.Produced, frame art.ID, deps *[]ids.AssetID, bind func(art.ID, *asset.RegionArtifact, *[]ids.AssetID)) error {
if frame == "" {
return nil
}
for n := range artifacts {
a := &artifacts[n]
if a.Kind != asset.ArtSpriteRegion {
continue
}
region, err := asset.DecodeRegion(a.Data)
if err != nil {
return err
}
bind(frame, region, deps)
a.Data = asset.EncodeRegion(region)
}
return nil
}
func appendUniqueAsset(list []ids.AssetID, id ids.AssetID) []ids.AssetID {
if !id.IsZero() && !slices.Contains(list, id) {
return append(list, id)
}
return list
}
// Art Core stores eight bits per channel. Do not silently reduce the source
// precision when creating a new editable document, including its surface maps.
// Existing linked imports continue to use decodeLinkedPNG for compatibility.
func decodeEditableTexturePNG(data []byte) (*image.NRGBA, error) {
if int64(len(data)) > art.DefaultLimits().MaxFileBytes {
return nil, art.ErrLimit
}
config, err := png.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, err
}
if err := art.CheckPNGPrecision(config); err != nil {
return nil, fmt.Errorf("artsource: %w", err)
}
return decodeTexturePNG(data, true)
}
func decodeLinkedPNG(data []byte) (*image.NRGBA, error) {
return decodeTexturePNG(data, false)
}
func decodeTexturePNG(data []byte, preservePalette bool) (*image.NRGBA, error) {
lim := art.DefaultLimits()
if int64(len(data)) > lim.MaxFileBytes {
return nil, art.ErrLimit
}
config, err := png.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > lim.MaxDimension || config.Height > lim.MaxDimension || int64(config.Width)*int64(config.Height) > int64(lim.MaxCanvasPixels) {
return nil, art.ErrLimit
}
source, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
pixels := image.NewNRGBA(source.Bounds())
if indexed, ok := source.(*image.Paletted); ok && preservePalette {
// draw.Draw converts a paletted source through premultiplied RGBA64,
// which discards RGB under zero alpha and may round partial-alpha
// entries. Preserve the PNG palette's straight channels in Art Core.
bounds := indexed.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
pixels.SetNRGBA(x, y, color.NRGBAModel.Convert(indexed.At(x, y)).(color.NRGBA))
}
}
return pixels, nil
}
draw.Draw(pixels, pixels.Bounds(), source, source.Bounds().Min, draw.Src)
return pixels, nil
}
func textureSourceFrames(meta *pipeline.Meta, bounds image.Rectangle) []pipeline.SubAsset {
if len(meta.Subs) != 0 {
return meta.Subs
}
return []pipeline.SubAsset{{ID: meta.ID, Name: "sprite", Rect: [4]int{0, 0, bounds.Dx(), bounds.Dy()}, Pivot: meta.Pivot, HasPivot: meta.HasPivot}}
}
func sourceFrameRect(f pipeline.SubAsset) image.Rectangle {
return image.Rect(f.Rect[0], f.Rect[1], f.Rect[0]+f.Rect[2], f.Rect[1]+f.Rect[3])
}
var _ pipeline.Importer = (*LinkedTextureImporter)(nil)
var _ pipeline.SourceDependencySalter = (*LinkedTextureImporter)(nil)