508 lines
11 KiB
Go
508 lines
11 KiB
Go
package preview
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"hash/fnv"
|
|
"image"
|
|
stddraw "image/draw"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"aego/asset"
|
|
"aego/asset/pipeline"
|
|
"aego/core/errs"
|
|
"aego/core/ids"
|
|
"aego/core/mathx"
|
|
"aego/editor/core/shape"
|
|
"aego/editor/core/tasks"
|
|
"aego/engine"
|
|
"aego/gpu"
|
|
"aego/render"
|
|
"aego/render/rt"
|
|
"aego/render/texture"
|
|
"aego/scene"
|
|
)
|
|
|
|
const (
|
|
Size = 128
|
|
CacheDir = "thumbs"
|
|
legacyDir = "thumbnails"
|
|
salt = "preview.v1"
|
|
lineCount = 30
|
|
CoverFile = "preview.png"
|
|
)
|
|
|
|
type Deps struct {
|
|
Engine *engine.Engine
|
|
Pipeline *pipeline.Pipeline
|
|
Renderer *render.Renderer
|
|
GPU gpu.Device
|
|
Types *scene.Types
|
|
Resolver asset.Resolver
|
|
Shapes *shape.Registry
|
|
Tasks *tasks.Queue
|
|
CacheRoot string
|
|
Background mathx.Color
|
|
Cover func() ids.AssetID
|
|
Reference func() mathx.Vec2
|
|
}
|
|
|
|
type state uint8
|
|
|
|
const (
|
|
pending state = iota
|
|
ready
|
|
failed
|
|
)
|
|
|
|
type item struct {
|
|
id ids.AssetID
|
|
key string
|
|
state state
|
|
tex gpu.TextureHandle
|
|
region texture.Region
|
|
lines []string
|
|
task *tasks.Task
|
|
}
|
|
|
|
type Service struct {
|
|
d Deps
|
|
dir string
|
|
items map[ids.AssetID]*item
|
|
frame int64
|
|
gpuOK bool
|
|
pool *rt.Pool
|
|
coverKey string
|
|
}
|
|
|
|
func New(d Deps) *Service {
|
|
s := &Service{d: d, items: map[ids.AssetID]*item{}, dir: filepath.Join(d.CacheRoot, CacheDir)}
|
|
if d.Engine != nil && d.GPU != nil && d.Renderer != nil {
|
|
s.gpuOK = !strings.Contains(strings.ToLower(d.GPU.Caps().Renderer), "null")
|
|
}
|
|
if d.Tasks != nil && d.CacheRoot != "" {
|
|
legacy := filepath.Join(d.CacheRoot, legacyDir)
|
|
d.Tasks.Work(tasks.Spec{Title: "Remove old thumbnails", Kind: "preview"}, func(*tasks.Task) error {
|
|
return os.RemoveAll(legacy)
|
|
})
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Service) Begin() {
|
|
s.frame++
|
|
if s.d.Cover == nil || !s.gpuOK {
|
|
return
|
|
}
|
|
if id := s.d.Cover(); !id.IsZero() {
|
|
s.ByID(id)
|
|
}
|
|
}
|
|
|
|
func Image(k asset.ArtifactKind) bool {
|
|
return k == asset.ArtTexture || k == asset.ArtSpriteRegion
|
|
}
|
|
|
|
func Rendered(k asset.ArtifactKind) bool {
|
|
return k == asset.ArtScene || k == asset.ArtPrefab
|
|
}
|
|
|
|
func (s *Service) entry(id ids.AssetID) (asset.Entry, bool) {
|
|
if s.d.Pipeline == nil || id.IsZero() {
|
|
return asset.Entry{}, false
|
|
}
|
|
return s.d.Pipeline.Entry(id)
|
|
}
|
|
|
|
func (s *Service) Key(e asset.Entry) (string, bool) {
|
|
art, ok := e.Artifact(e.Kind)
|
|
if (!ok || art.Hash == "") && e.Kind == asset.ArtScript {
|
|
return s.scriptKey(e)
|
|
}
|
|
if !ok || art.Hash == "" {
|
|
return "", false
|
|
}
|
|
h := fnv.New64a()
|
|
h.Write([]byte(salt))
|
|
h.Write([]byte(art.Hash))
|
|
if Rendered(e.Kind) {
|
|
deps := slices.Clone(e.Deps)
|
|
slices.SortFunc(deps, func(a, b ids.AssetID) int { return strings.Compare(a.String(), b.String()) })
|
|
for _, d := range deps {
|
|
if de, ok := s.entry(d); ok {
|
|
if da, ok := de.Artifact(de.Kind); ok {
|
|
h.Write([]byte(da.Hash))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), true
|
|
}
|
|
|
|
func (s *Service) scriptKey(e asset.Entry) (string, bool) {
|
|
h := fnv.New64a()
|
|
h.Write([]byte(salt))
|
|
h.Write([]byte(e.Source))
|
|
for _, d := range e.Deps {
|
|
if de, ok := s.entry(d); ok {
|
|
for _, a := range de.Artifacts {
|
|
h.Write([]byte(a.Hash))
|
|
}
|
|
}
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), true
|
|
}
|
|
|
|
func (s *Service) ByID(id ids.AssetID) (texture.Region, bool) {
|
|
e, ok := s.entry(id)
|
|
if !ok {
|
|
return texture.Region{}, false
|
|
}
|
|
return s.Get(pipeline.AssetInfo{ID: id, Kind: e.Kind, Path: e.Source})
|
|
}
|
|
|
|
func (s *Service) Get(info pipeline.AssetInfo) (texture.Region, bool) {
|
|
if !Image(info.Kind) && !(Rendered(info.Kind) && s.gpuOK) {
|
|
return texture.Region{}, false
|
|
}
|
|
it := s.ensure(info)
|
|
if it == nil {
|
|
return texture.Region{}, false
|
|
}
|
|
return it.region, !it.region.IsZero()
|
|
}
|
|
|
|
func (s *Service) Missing(info pipeline.AssetInfo) bool {
|
|
switch {
|
|
case Image(info.Kind):
|
|
case Rendered(info.Kind):
|
|
if !s.gpuOK {
|
|
return true
|
|
}
|
|
default:
|
|
return true
|
|
}
|
|
it := s.items[info.ID]
|
|
return it != nil && it.state == failed && it.region.IsZero()
|
|
}
|
|
|
|
func (s *Service) Lines(info pipeline.AssetInfo) []string {
|
|
if info.Kind != asset.ArtScript {
|
|
return nil
|
|
}
|
|
if it := s.ensure(info); it != nil {
|
|
return it.lines
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) ensure(info pipeline.AssetInfo) *item {
|
|
e, ok := s.entry(info.ID)
|
|
if !ok || s.d.Tasks == nil {
|
|
return nil
|
|
}
|
|
key, ok := s.Key(e)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
it := s.items[info.ID]
|
|
if it != nil && it.key == key {
|
|
if it.state == pending && it.task != nil {
|
|
it.task.SetPriority(s.frame)
|
|
}
|
|
return it
|
|
}
|
|
if it == nil {
|
|
it = &item{id: info.ID}
|
|
s.items[info.ID] = it
|
|
}
|
|
if it.task != nil {
|
|
it.task.Cancel()
|
|
}
|
|
it.key, it.state = key, pending
|
|
spec := tasks.Spec{Title: "Preview " + filepath.Base(e.Name), Kind: "preview", Priority: s.frame}
|
|
switch {
|
|
case info.Kind == asset.ArtScript:
|
|
s.startLines(info, it, spec)
|
|
case Image(e.Kind):
|
|
s.startImage(e, it, spec)
|
|
default:
|
|
s.startScene(e, it, spec)
|
|
}
|
|
return it
|
|
}
|
|
|
|
func (s *Service) startLines(info pipeline.AssetInfo, it *item, spec tasks.Spec) {
|
|
key, abs := it.key, s.d.Pipeline.Abs(info.Path)
|
|
var lines []string
|
|
it.task = s.d.Tasks.Work(spec, func(*tasks.Task) error {
|
|
data, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines = firstLines(string(data), lineCount)
|
|
return nil
|
|
}).Then(func(t *tasks.Task) {
|
|
if it.key != key {
|
|
return
|
|
}
|
|
it.task = nil
|
|
if t.State() != tasks.Done {
|
|
it.state = failed
|
|
return
|
|
}
|
|
it.lines, it.state = lines, ready
|
|
})
|
|
}
|
|
|
|
func firstLines(src string, n int) []string {
|
|
var out []string
|
|
imports := false
|
|
for _, l := range strings.Split(src, "\n") {
|
|
l = strings.TrimRight(strings.ReplaceAll(l, "\t", " "), " \r")
|
|
t := strings.TrimSpace(l)
|
|
switch {
|
|
case imports:
|
|
imports = t != ")"
|
|
continue
|
|
case strings.HasPrefix(t, "import ("):
|
|
imports = true
|
|
continue
|
|
case t == "" || strings.HasPrefix(l, "package ") || strings.HasPrefix(t, "import ") || strings.HasPrefix(t, "//"):
|
|
continue
|
|
}
|
|
out = append(out, l)
|
|
if len(out) == n {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type pixels struct {
|
|
buf []byte
|
|
w, h int
|
|
}
|
|
|
|
func (s *Service) startImage(e asset.Entry, it *item, spec tasks.Spec) {
|
|
key := it.key
|
|
var out pixels
|
|
it.task = s.d.Tasks.Work(spec, func(t *tasks.Task) error {
|
|
if p, err := s.load(key); err == nil {
|
|
out = p
|
|
return nil
|
|
}
|
|
p, err := s.buildImage(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = p
|
|
return s.save(key, p)
|
|
}).Then(func(t *tasks.Task) { s.finish(it, key, out, t) })
|
|
}
|
|
|
|
func (s *Service) finish(it *item, key string, p pixels, t *tasks.Task) {
|
|
if it.key != key {
|
|
return
|
|
}
|
|
it.task = nil
|
|
if t.State() != tasks.Done || len(p.buf) == 0 {
|
|
it.state = failed
|
|
return
|
|
}
|
|
data := &texture.Data{Width: p.w, Height: p.h, Format: gpu.TexSRGBA8, Pixels: p.buf, Premultiplied: true}
|
|
tex, err := s.d.Renderer.Textures().Upload(data, texture.UploadOptions{Label: "editor.preview"})
|
|
if err != nil {
|
|
it.state = failed
|
|
return
|
|
}
|
|
if !it.tex.IsZero() {
|
|
s.d.GPU.DestroyTexture(it.tex)
|
|
}
|
|
it.tex, it.region, it.state = tex, texture.Whole(tex, p.w, p.h), ready
|
|
s.cover(it, p)
|
|
}
|
|
|
|
func (s *Service) cover(it *item, p pixels) {
|
|
if s.d.Cover == nil || s.d.CacheRoot == "" || it.id != s.d.Cover() || it.key == s.coverKey {
|
|
return
|
|
}
|
|
s.coverKey = it.key
|
|
dst := filepath.Join(s.d.CacheRoot, CoverFile)
|
|
s.d.Tasks.Work(tasks.Spec{Title: "Project preview", Kind: "preview"}, func(*tasks.Task) error {
|
|
var buf bytes.Buffer
|
|
img := &image.NRGBA{Pix: p.buf, Stride: p.w * 4, Rect: image.Rect(0, 0, p.w, p.h)}
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return err
|
|
}
|
|
tmp := dst + ".tmp"
|
|
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, dst)
|
|
})
|
|
}
|
|
|
|
func (s *Service) cachePath(key string) string {
|
|
return filepath.Join(s.dir, key+".png")
|
|
}
|
|
|
|
func (s *Service) load(key string) (pixels, error) {
|
|
data, err := os.ReadFile(s.cachePath(key))
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
img, err := png.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
n, ok := img.(*image.NRGBA)
|
|
if !ok {
|
|
n = image.NewNRGBA(img.Bounds())
|
|
stddraw.Draw(n, n.Bounds(), img, img.Bounds().Min, stddraw.Src)
|
|
}
|
|
b := n.Bounds()
|
|
return pixels{buf: n.Pix, w: b.Dx(), h: b.Dy()}, nil
|
|
}
|
|
|
|
func (s *Service) save(key string, p pixels) error {
|
|
var buf bytes.Buffer
|
|
img := &image.NRGBA{Pix: p.buf, Stride: p.w * 4, Rect: image.Rect(0, 0, p.w, p.h)}
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.cachePath(key) + ".tmp"
|
|
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.cachePath(key))
|
|
}
|
|
|
|
func (s *Service) buildImage(e asset.Entry) (pixels, error) {
|
|
files := s.d.Pipeline.Artifacts()
|
|
art, _ := e.Artifact(e.Kind)
|
|
data, err := files.ReadFile(art.Path)
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
var buf []byte
|
|
var w, h int
|
|
switch e.Kind {
|
|
case asset.ArtTexture:
|
|
ta, err := asset.DecodeTexture(data)
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
buf, w, h = ta.Pixels, ta.Width, ta.Height
|
|
case asset.ArtSpriteRegion:
|
|
ra, err := asset.DecodeRegion(data)
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
pe, ok := s.entry(ra.Page)
|
|
if !ok {
|
|
return pixels{}, errs.New(errs.AssetNotFound, "atlas page is missing")
|
|
}
|
|
pa, ok := pe.Artifact(pe.Kind)
|
|
if !ok {
|
|
return pixels{}, errs.New(errs.AssetNotFound, "atlas page artifact is missing")
|
|
}
|
|
pdata, err := files.ReadFile(pa.Path)
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
ta, err := asset.DecodeTexture(pdata)
|
|
if err != nil {
|
|
return pixels{}, err
|
|
}
|
|
buf, w, h = crop(ta.Pixels, ta.Width, ra.X, ra.Y, ra.W, ra.H), ra.W, ra.H
|
|
default:
|
|
return pixels{}, errs.New(errs.AssetBadArtifact, "asset has no image")
|
|
}
|
|
small, sw, sh := downscale(buf, w, h, Size)
|
|
return pixels{buf: small, w: sw, h: sh}, nil
|
|
}
|
|
|
|
func crop(src []byte, stride, x, y, w, h int) []byte {
|
|
out := make([]byte, w*h*4)
|
|
for row := 0; row < h; row++ {
|
|
si := ((y+row)*stride + x) * 4
|
|
copy(out[row*w*4:(row+1)*w*4], src[si:si+w*4])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func downscale(src []byte, w, h, limit int) ([]byte, int, int) {
|
|
if w <= limit && h <= limit {
|
|
return append([]byte(nil), src[:w*h*4]...), w, h
|
|
}
|
|
f := float64(max(w, h)) / float64(limit)
|
|
nw, nh := max(1, int(float64(w)/f)), max(1, int(float64(h)/f))
|
|
out := make([]byte, nw*nh*4)
|
|
for y := 0; y < nh; y++ {
|
|
y0 := int(float64(y) * f)
|
|
y1 := min(h, max(y0+1, int(float64(y+1)*f)))
|
|
for x := 0; x < nw; x++ {
|
|
x0 := int(float64(x) * f)
|
|
x1 := min(w, max(x0+1, int(float64(x+1)*f)))
|
|
var sum [4]int
|
|
n := 0
|
|
for yy := y0; yy < y1; yy++ {
|
|
for xx := x0; xx < x1; xx++ {
|
|
i := (yy*w + xx) * 4
|
|
sum[0] += int(src[i])
|
|
sum[1] += int(src[i+1])
|
|
sum[2] += int(src[i+2])
|
|
sum[3] += int(src[i+3])
|
|
n++
|
|
}
|
|
}
|
|
o := (y*nw + x) * 4
|
|
for c := 0; c < 4; c++ {
|
|
out[o+c] = byte(sum[c] / n)
|
|
}
|
|
}
|
|
}
|
|
return out, nw, nh
|
|
}
|
|
|
|
func (s *Service) Forget(id ids.AssetID) {
|
|
it, ok := s.items[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
if it.task != nil {
|
|
it.task.Cancel()
|
|
}
|
|
if !it.tex.IsZero() {
|
|
s.d.GPU.DestroyTexture(it.tex)
|
|
}
|
|
delete(s.items, id)
|
|
}
|
|
|
|
func (s *Service) Busy() bool {
|
|
for _, it := range s.items {
|
|
if it.state == pending {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) Close() {
|
|
for id := range s.items {
|
|
s.Forget(id)
|
|
}
|
|
if s.pool != nil {
|
|
s.pool.Close()
|
|
s.pool = nil
|
|
}
|
|
}
|