Stage 2: render pipeline and UI toolkit over gpu/platform. Stage 3: schema registry, asset pipeline + runtime loader, scene graph and ECS. Stage 4: editor — docking, inspector, scene/game views, play mode, asset browser, console, profiler; 4.5 adds the 3D foundation (mixed 2D/3D spaces, meshes, depth). Stage 5: Aego Script — compiler (lexer/parser/types/resolver/checker/asm/compiler), bytecode with verifier and register VM, instance runtime, stdlib, native bindings, and editor/CLI integration (aego run/edit/check/test/dump).
421 lines
10 KiB
Go
421 lines
10 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"aego/asset"
|
|
"aego/core/errs"
|
|
"aego/core/ids"
|
|
"aego/core/log"
|
|
)
|
|
|
|
type AssetInfo struct {
|
|
ID ids.AssetID
|
|
Path string
|
|
Name string
|
|
Kind asset.ArtifactKind
|
|
Importer string
|
|
Error string
|
|
}
|
|
|
|
func (p *Pipeline) Assets() []AssetInfo {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]AssetInfo, 0, len(p.byID))
|
|
for _, r := range p.byID {
|
|
info := AssetInfo{ID: r.id, Path: r.path, Name: assetName(r.path), Error: r.err}
|
|
if r.importer != nil {
|
|
info.Importer = r.importer.ID()
|
|
}
|
|
if e, ok := p.index.Lookup(r.id); ok {
|
|
info.Kind = e.Kind
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out
|
|
}
|
|
|
|
func (p *Pipeline) Entry(id ids.AssetID) (asset.Entry, bool) {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
e, ok := p.index.Lookup(id)
|
|
if !ok {
|
|
return asset.Entry{}, false
|
|
}
|
|
return *e, true
|
|
}
|
|
|
|
func (p *Pipeline) RootDirs() []string {
|
|
out := make([]string, len(p.roots))
|
|
for i, r := range p.roots {
|
|
out[i] = p.rel(r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Pipeline) Abs(rel string) string {
|
|
return p.abs(rel)
|
|
}
|
|
|
|
func (p *Pipeline) Dirs() []string {
|
|
var out []string
|
|
for _, root := range p.roots {
|
|
filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
|
if err != nil || !d.IsDir() {
|
|
return nil
|
|
}
|
|
if full != root && strings.HasPrefix(d.Name(), ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
out = append(out, p.rel(full))
|
|
return nil
|
|
})
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func cleanRel(rel string) string {
|
|
return path.Clean(filepath.ToSlash(strings.TrimSpace(rel)))
|
|
}
|
|
|
|
func (p *Pipeline) inRoots(rel string) bool {
|
|
for _, root := range p.RootDirs() {
|
|
if rel == root || strings.HasPrefix(rel, root+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Pipeline) isRoot(rel string) bool {
|
|
for _, root := range p.RootDirs() {
|
|
if rel == root {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func badPath(rel, msg string) error {
|
|
return errs.New(errs.VFSBadPath, msg, log.F("path", rel))
|
|
}
|
|
|
|
func exists(abs string) bool {
|
|
_, err := os.Stat(abs)
|
|
return err == nil
|
|
}
|
|
|
|
func (p *Pipeline) unique(rel string) string {
|
|
ext := path.Ext(rel)
|
|
base := strings.TrimSuffix(rel, ext)
|
|
candidate := rel
|
|
for n := 2; ; n++ {
|
|
if _, taken := p.byPath[candidate]; !taken && !exists(p.abs(candidate)) {
|
|
return candidate
|
|
}
|
|
candidate = base + " " + strconv.Itoa(n) + ext
|
|
}
|
|
}
|
|
|
|
func (p *Pipeline) UniqueDir(rel string) string {
|
|
rel = cleanRel(rel)
|
|
candidate := rel
|
|
for n := 2; exists(p.abs(candidate)); n++ {
|
|
candidate = rel + " " + strconv.Itoa(n)
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func (p *Pipeline) Mkdir(rel string) error {
|
|
rel = cleanRel(rel)
|
|
if !p.inRoots(rel) {
|
|
return badPath(rel, "folder must be inside assets or scenes")
|
|
}
|
|
if err := os.MkdirAll(p.abs(rel), 0o755); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot create the folder")
|
|
}
|
|
p.mu.Lock()
|
|
p.rev++
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *Pipeline) Move(id ids.AssetID, newRel string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
r, ok := p.byID[id]
|
|
if !ok {
|
|
return errs.New(errs.AssetNotFound, "asset is not known to the pipeline", log.F("asset", id.String()))
|
|
}
|
|
newRel = cleanRel(newRel)
|
|
if newRel == r.path {
|
|
return nil
|
|
}
|
|
if !p.inRoots(path.Dir(newRel)) {
|
|
return badPath(newRel, "destination must be inside assets or scenes")
|
|
}
|
|
if path.Ext(newRel) != path.Ext(r.path) {
|
|
return badPath(newRel, "renaming cannot change the file extension")
|
|
}
|
|
if _, taken := p.byPath[newRel]; taken || exists(p.abs(newRel)) {
|
|
return badPath(newRel, "destination already exists")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p.abs(newRel)), 0o755); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot create the destination folder")
|
|
}
|
|
oldAbs, newAbs := p.abs(r.path), p.abs(newRel)
|
|
if err := os.Rename(oldAbs, newAbs); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot move the asset")
|
|
}
|
|
if exists(oldAbs + MetaSuffix) {
|
|
if err := os.Rename(oldAbs+MetaSuffix, newAbs+MetaSuffix); err != nil {
|
|
os.Rename(newAbs, oldAbs)
|
|
return errs.Wrap(err, errs.VFSIO, "cannot move the meta file")
|
|
}
|
|
}
|
|
p.relocate(r, newRel)
|
|
p.rev++
|
|
return p.saveIndex()
|
|
}
|
|
|
|
func (p *Pipeline) relocate(r *record, newRel string) {
|
|
delete(p.byPath, r.path)
|
|
r.path = newRel
|
|
p.byPath[newRel] = r
|
|
if e, ok := p.index.Lookup(r.id); ok {
|
|
e.Source = newRel
|
|
e.Name = assetName(newRel)
|
|
}
|
|
if r.group != "" {
|
|
p.stale[r.group] = true
|
|
delete(p.groupKey, r.group)
|
|
}
|
|
}
|
|
|
|
func (p *Pipeline) MoveDir(oldRel, newRel string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
oldRel, newRel = cleanRel(oldRel), cleanRel(newRel)
|
|
if oldRel == newRel {
|
|
return nil
|
|
}
|
|
if p.isRoot(oldRel) || !p.inRoots(oldRel) || !p.inRoots(newRel) {
|
|
return badPath(oldRel, "only folders inside assets or scenes can be moved")
|
|
}
|
|
if strings.HasPrefix(newRel, oldRel+"/") {
|
|
return badPath(newRel, "a folder cannot be moved into itself")
|
|
}
|
|
if exists(p.abs(newRel)) {
|
|
return badPath(newRel, "destination already exists")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p.abs(newRel)), 0o755); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot create the destination folder")
|
|
}
|
|
if err := os.Rename(p.abs(oldRel), p.abs(newRel)); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "cannot move the folder")
|
|
}
|
|
var moved []*record
|
|
for _, r := range p.byID {
|
|
if strings.HasPrefix(r.path, oldRel+"/") {
|
|
moved = append(moved, r)
|
|
}
|
|
}
|
|
for _, r := range moved {
|
|
p.relocate(r, newRel+strings.TrimPrefix(r.path, oldRel))
|
|
}
|
|
p.rev++
|
|
return p.saveIndex()
|
|
}
|
|
|
|
func (p *Pipeline) RemoveDir(rel string) error {
|
|
rel = cleanRel(rel)
|
|
if p.isRoot(rel) || !p.inRoots(rel) {
|
|
return badPath(rel, "only folders inside assets or scenes can be removed")
|
|
}
|
|
if err := os.Remove(p.abs(rel)); err != nil {
|
|
return errs.Wrap(err, errs.VFSIO, "folder is not empty or cannot be removed")
|
|
}
|
|
p.mu.Lock()
|
|
p.rev++
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *Pipeline) Duplicate(id ids.AssetID) (ids.AssetID, *Batch, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
r, ok := p.byID[id]
|
|
if !ok {
|
|
return ids.AssetID{}, nil, errs.New(errs.AssetNotFound, "asset is not known to the pipeline", log.F("asset", id.String()))
|
|
}
|
|
ext := path.Ext(r.path)
|
|
dst := p.unique(strings.TrimSuffix(r.path, ext) + " copy" + ext)
|
|
if err := copyFile(p.abs(r.path), p.abs(dst)); err != nil {
|
|
return ids.AssetID{}, nil, errs.Wrap(err, errs.VFSIO, "cannot copy the asset")
|
|
}
|
|
meta := &Meta{ID: ids.NewAssetID(), Importer: r.meta.Importer, Version: r.meta.Version, Settings: r.meta.Settings.Clone()}
|
|
if err := os.WriteFile(p.abs(dst)+MetaSuffix, WriteMeta(meta), 0o644); err != nil {
|
|
os.Remove(p.abs(dst))
|
|
return ids.AssetID{}, nil, errs.Wrap(err, errs.VFSIO, "cannot write the meta file")
|
|
}
|
|
if _, _, err := p.scan(); err != nil {
|
|
return ids.AssetID{}, nil, err
|
|
}
|
|
batch, err := p.importIDs(meta.ID)
|
|
return meta.ID, batch, err
|
|
}
|
|
|
|
func (p *Pipeline) Delete(id ids.AssetID) (*Batch, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
r, ok := p.byID[id]
|
|
if !ok {
|
|
return nil, errs.New(errs.AssetNotFound, "asset is not known to the pipeline", log.F("asset", id.String()))
|
|
}
|
|
full := p.abs(r.path)
|
|
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
|
return nil, errs.Wrap(err, errs.VFSIO, "cannot delete the asset")
|
|
}
|
|
os.Remove(full + MetaSuffix)
|
|
if _, _, err := p.scan(); err != nil {
|
|
return nil, err
|
|
}
|
|
batch, err := p.importIDs()
|
|
if batch == nil {
|
|
batch = &Batch{Failed: map[ids.AssetID]error{}}
|
|
}
|
|
batch.Removed = append(batch.Removed, id)
|
|
return batch, err
|
|
}
|
|
|
|
func (p *Pipeline) Reimport(list ...ids.AssetID) (*Batch, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for _, id := range list {
|
|
if r := p.byID[id]; r != nil {
|
|
r.key = ""
|
|
if r.group != "" {
|
|
p.groupKey[r.group] = ""
|
|
}
|
|
}
|
|
}
|
|
return p.importIDs(list...)
|
|
}
|
|
|
|
func convertible(ext string) bool {
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".gif":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func convertToPNG(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
img, _, err := image.Decode(in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := png.Encode(out, img); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func (p *Pipeline) ImportExternal(srcs []string, dstDir string) ([]ids.AssetID, *Batch, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
dstDir = cleanRel(dstDir)
|
|
if !p.inRoots(dstDir) {
|
|
return nil, nil, badPath(dstDir, "files can only be imported into assets or scenes")
|
|
}
|
|
if err := os.MkdirAll(p.abs(dstDir), 0o755); err != nil {
|
|
return nil, nil, errs.Wrap(err, errs.VFSIO, "cannot create the destination folder")
|
|
}
|
|
_, pngOK := p.imps.ForPath("image.png")
|
|
targets := map[string]bool{}
|
|
var skipped []string
|
|
for _, src := range srcs {
|
|
base := filepath.Base(src)
|
|
info, err := os.Stat(src)
|
|
if err != nil || info.IsDir() {
|
|
skipped = append(skipped, base)
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(src))
|
|
if convertible(ext) && pngOK {
|
|
rel := p.unique(path.Join(dstDir, strings.TrimSuffix(base, filepath.Ext(base))+".png"))
|
|
if err := convertToPNG(src, p.abs(rel)); err != nil {
|
|
return nil, nil, errs.Wrap(err, errs.AssetImportFailed, "cannot convert the dropped image to png").With(log.F("src", src))
|
|
}
|
|
targets[rel] = true
|
|
continue
|
|
}
|
|
if _, ok := p.imps.ForPath(src); !ok {
|
|
skipped = append(skipped, base)
|
|
continue
|
|
}
|
|
rel := p.unique(path.Join(dstDir, base))
|
|
if err := copyFile(src, p.abs(rel)); err != nil {
|
|
return nil, nil, errs.Wrap(err, errs.VFSIO, "cannot copy the dropped file").With(log.F("src", src))
|
|
}
|
|
targets[rel] = true
|
|
}
|
|
if len(targets) == 0 {
|
|
return nil, nil, errs.New(errs.AssetImportFailed, "none of the dropped files can be imported",
|
|
log.F("files", strings.Join(skipped, ", ")))
|
|
}
|
|
added, _, err := p.scan()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var created []ids.AssetID
|
|
for _, id := range added {
|
|
if r := p.byID[id]; r != nil && targets[r.path] {
|
|
created = append(created, id)
|
|
}
|
|
}
|
|
batch, err := p.importIDs(created...)
|
|
return created, batch, err
|
|
}
|