Files
sanspie a46347e2c6
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 23m57s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
editor: add art studio and stage 12.5 workflows
2026-11-08 18:47:57 +03:00

529 lines
16 KiB
Go

package context
import (
"bytes"
"fmt"
"strings"
"aego/asset"
"aego/asset/pipeline"
"aego/core/ids"
"aego/editor/core/code"
"aego/editor/core/document"
"aego/gpu"
"aego/vfs"
)
// A candidate contains the complete working metadata snapshot, so only the
// latest pending snapshot is useful. There is one worker plus one pending job.
const (
maxMetadataRequests = 1
metadataRestoreRetryDelay = 1.0
)
type metadataRequest struct {
doc *document.AssetDocument
metadata *document.ScriptDocument
asset ids.AssetID
path string
revision, saved, device, pipeline, token uint64
source []byte
overlay map[string][]byte
versions map[string]metadataVersion
working map[ids.AssetID]string
sources sourcePreviewState
restore bool
}
type metadataVersion struct {
doc *document.ScriptDocument
code *code.Doc
revision, saved uint64
}
type metadataCoordinator struct {
queue []*metadataRequest
running bool
closed bool
next uint64
tracked map[document.ID]*document.AssetDocument
working map[ids.AssetID]string
sourceWorking map[ids.AssetID]string
sourceVersions map[ids.AssetID]sourcePreviewVersion
published []ids.AssetID
retry *metadataRequest
rollback error
rollbackDevice uint64
rollbackAfter float64
unsubscribe func()
files vfs.FS
// Scheduling and CPU preparation are independent of GPU publication. Tests
// drive these seams to finish old jobs after newer edits/context loss.
submit, onMain func(func())
build func(*metadataRequest) (*pipeline.Batch, vfs.FS, error)
device func() (uint64, bool)
}
type metadataPipeline interface {
PreviewMetadata(ids.AssetID, []byte, func(string) ([]byte, bool)) (*pipeline.Batch, vfs.FS, error)
}
func (e *Editor) metadataDevice() (uint64, bool) {
if e.metadata != nil && e.metadata.device != nil {
return e.metadata.device()
}
if e.Engine != nil {
if d, ok := e.Engine.GPU().(gpu.ContextState); ok {
return d.ContextGeneration(), d.ContextLost()
}
}
return 0, false
}
func (e *Editor) metadataService() *metadataCoordinator {
if e.metadata != nil {
return e.metadata
}
q := &metadataCoordinator{tracked: make(map[document.ID]*document.AssetDocument), working: make(map[ids.AssetID]string)}
if e.Engine != nil {
q.submit, q.onMain = e.Engine.Jobs().Submit, e.Engine.Jobs().OnMain
}
q.build = func(r *metadataRequest) (*pipeline.Batch, vfs.FS, error) {
p, ok := any(e.Pipeline).(metadataPipeline)
if !ok {
return nil, nil, fmt.Errorf("the pipeline cannot prepare working metadata")
}
return p.PreviewMetadata(r.asset, r.source, func(name string) ([]byte, bool) { b, ok := r.overlay[name]; return b, ok })
}
e.metadata = q
if e.Docs != nil {
q.unsubscribe = e.Docs.Subscribe(func(ev document.Event) {
if ev.Kind != document.EvClosed || q.closed {
return
}
d := q.tracked[ev.Doc]
if d == nil {
return
}
delete(q.tracked, ev.Doc)
// Closing/discarding a working document restores saved artifacts;
// an old pending edit may never publish after its document closes.
if err := e.queueMetadata(d, true); err != nil {
q.next++
q.retry = &metadataRequest{doc: d, asset: d.Asset, restore: true}
if _, lost := e.metadataDevice(); !lost {
err = e.restoreSavedMetadata(q, err)
q.retry = nil
}
e.Notify(LevelError, err.Error())
}
})
}
return q
}
// ApplyMetadataPreview imports a working buffer without saving: one CPU job runs at a time, newer requests replace the queued snapshot, and publication uses the engine-thread asset reload route.
func (e *Editor) ApplyMetadataPreview(id ids.AssetID) error {
if e.Docs == nil {
return fmt.Errorf("document manager is unavailable")
}
d := e.Docs.AssetDocument(id)
if d == nil {
return fmt.Errorf("metadata document is no longer open")
}
return e.queueMetadata(d, false)
}
func (e *Editor) queueMetadata(d *document.AssetDocument, restore bool) error {
if e.Pipeline == nil || e.Loader == nil || d == nil || d.Metadata == nil {
return fmt.Errorf("working metadata requires an asset loader and metadata document")
}
q := e.metadataService()
if q.closed || q.submit == nil || q.onMain == nil {
return fmt.Errorf("working metadata jobs are unavailable")
}
if !restore && (d.Metadata.ReadOnly || d.Metadata.Conflict) {
return fmt.Errorf("resolve the metadata conflict before previewing it")
}
device, _ := e.metadataDevice()
r := &metadataRequest{doc: d, metadata: d.Metadata, asset: d.Asset, path: d.Path, revision: d.Metadata.Code.Rev(), saved: d.Metadata.SavedEdits, device: device, pipeline: e.Pipeline.Revision(), restore: restore, overlay: make(map[string][]byte), versions: make(map[string]metadataVersion), working: make(map[ids.AssetID]string)}
// Snapshot every owner from the last published working generation, using saved metadata for closed ones, so a later superseding close cannot erase an earlier restore.
for id, path := range q.working {
if current := e.Docs.AssetDocument(id); current != nil && current.Metadata != nil {
path = current.Path
if current.Metadata.Conflict {
return fmt.Errorf("resolve the metadata conflict for %s before updating working assets", path)
}
s := current.Metadata
r.overlay[path+".meta"] = bytes.Clone(s.Code.Bytes())
r.versions[s.Path] = metadataVersion{s, s.Code, s.Code.Rev(), s.SavedEdits}
if s.Dirty() {
r.working[id] = path
}
} else {
raw, err := e.Docs.ReadSource(path + ".meta")
if err != nil {
return fmt.Errorf("restore metadata for %s: %w", path, err)
}
r.overlay[path+".meta"] = raw
}
}
if restore {
data, err := e.Docs.ReadSource(d.Path + ".meta")
if err != nil {
return err
}
r.source = data
} else {
r.source = bytes.Clone(d.Metadata.Code.Bytes())
}
for _, other := range e.Docs.Scripts() {
if other.ExplicitSave && other.SavedEdits > 0 && other.Dirty() && !other.Conflict && strings.HasSuffix(other.Path, ".meta") {
path := strings.TrimSuffix(other.Path, ".meta")
id, ok := e.Pipeline.Lookup(path)
if !ok {
continue
}
meta, ok := e.Pipeline.MetaOf(id)
structured := e.Docs.AssetDocument(id)
if !ok || (meta.Importer != "texture" && (structured == nil || structured.Binding.PreviewSnapshot == nil)) {
continue
}
r.overlay[other.Path] = bytes.Clone(other.Code.Bytes())
r.versions[other.Path] = metadataVersion{other, other.Code, other.Code.Rev(), other.SavedEdits}
r.working[id] = path
}
}
r.overlay[d.Path+".meta"] = r.source
if !restore {
r.versions[d.Metadata.Path] = metadataVersion{d.Metadata, d.Metadata.Code, r.revision, r.saved}
if d.Metadata.Dirty() {
r.working[d.Asset] = d.Path
}
}
if err := e.captureWorkingSources(q, r); err != nil {
return err
}
q.next++
r.token = q.next
q.retry = nil
for _, previous := range q.queue {
if !previous.restore && previous.doc != d {
previous.doc.WorkingPreview.Pending = false
}
}
q.queue = append(q.queue[:0], r)
if !restore {
q.tracked[d.ID] = d
d.WorkingPreview.Pending = true
d.WorkingPreview.Revision = r.revision
d.WorkingPreview.Error = ""
}
e.pumpMetadata(q)
return nil
}
func (e *Editor) metadataCurrent(q *metadataCoordinator, r *metadataRequest) bool {
if q.closed || q.next != r.token || e.Pipeline.Revision() != r.pipeline {
return false
}
if !e.workingSourcesCurrent(r) {
return false
}
for path, v := range r.versions {
if e.Docs.Script(path) != v.doc || v.doc.Code != v.code || v.doc.Conflict || v.doc.Code.Rev() != v.revision || v.doc.SavedEdits != v.saved {
return false
}
}
device, lost := e.metadataDevice()
if lost || device != r.device {
return false
}
d := e.Docs.AssetDocument(r.asset)
if r.restore {
// A newly reopened clean view can observe the restored saved generation.
// A changed/saved replacement document must prepare its own candidate.
return d == nil || (d.Metadata != nil && !d.Metadata.Conflict && d.Metadata.SavedEdits == 0)
}
return d == r.doc && d.Path == r.path && d.Metadata == r.metadata && !r.metadata.Conflict &&
r.metadata.Code.Rev() == r.revision && r.metadata.SavedEdits == r.saved
}
func (e *Editor) pumpMetadata(q *metadataCoordinator) {
if q.running || q.closed || len(q.queue) == 0 {
return
}
r := q.queue[0]
q.queue[0] = nil
q.queue = q.queue[:0]
q.running = true
q.submit(func() {
batch, files, err := q.build(r)
q.onMain(func() {
q.running = false
current := e.metadataCurrent(q, r)
if current {
if err == nil {
if batch == nil || files == nil {
err = fmt.Errorf("metadata importer returned no prepared working generation")
} else if len(batch.Failed) != 0 {
for _, failed := range batch.Failed {
err = failed
break
}
} else {
e.pruneWorkingMetadata(q, batch)
err = e.applyBatch(batch, files)
}
}
if err == nil {
q.files = files
q.rollback = nil
q.working = r.working
e.publishedWorkingSources(q, r)
q.published = q.published[:0]
for _, entry := range batch.Imported {
q.published = append(q.published, entry.ID)
}
for _, d := range e.Docs.AssetDocuments() {
if d.Metadata == nil {
continue
}
if v, ok := r.versions[d.Metadata.Path]; ok && v.doc == d.Metadata {
d.WorkingPreview.Pending = false
d.WorkingPreview.Revision = v.revision
d.WorkingPreview.Applied = r.token
d.WorkingPreview.Error = ""
q.tracked[d.ID] = d
}
}
}
if !r.restore {
r.doc.WorkingPreview.Pending = false
if err == nil {
r.doc.WorkingPreview.Applied = r.token
r.doc.WorkingPreview.Error = ""
} else {
r.doc.WorkingPreview.Error = err.Error()
}
}
if err != nil {
if r.restore {
err = e.restoreSavedMetadata(q, err)
}
for _, d := range e.Docs.AssetDocuments() {
if d.Metadata != nil {
if v, ok := r.versions[d.Metadata.Path]; ok && v.doc == d.Metadata {
d.WorkingPreview.Pending = false
d.WorkingPreview.Error = err.Error()
}
}
}
e.Notify(LevelError, err.Error())
}
} else if !q.closed && q.next == r.token {
if !r.restore {
r.doc.WorkingPreview.Pending = false
}
// If the device generation or a saved import changed during CPU work, retry with fresh source/document snapshots on a later frame; never publish for the old device.
q.retry = r
}
e.pumpMetadata(q)
})
})
}
func (e *Editor) pruneWorkingMetadata(q *metadataCoordinator, batch *pipeline.Batch) {
retained := make(map[ids.AssetID]bool, len(batch.Imported)+len(batch.Removed))
for _, entry := range batch.Imported {
retained[entry.ID] = true
}
for _, id := range batch.Removed {
retained[id] = true
}
// Compare the prior published generation before replacing the private VFS: Undo's importer cannot remove private frame/atlas IDs the persistent index never saw.
for _, id := range q.published {
if !retained[id] {
if _, saved := e.Pipeline.Entry(id); !saved {
batch.Removed = append(batch.Removed, id)
}
}
}
}
// PollAssetPreviews resumes superseded device/import work when the context recovers; failed imports remain diagnostic rather than retrying every frame, but edit/Undo/Save can retry.
func (e *Editor) PollAssetPreviews() {
q := e.metadata
if q == nil || q.closed || q.running || len(q.queue) != 0 {
return
}
device, lost := e.metadataDevice()
if lost {
return
}
if q.rollback != nil {
// Keep one rollback after a failed GPU upload; retry promptly on a fresh context, at most once per second on the same device, and never while the device is lost.
if device == q.rollbackDevice && e.Now() < q.rollbackAfter {
return
}
err := e.restoreSavedMetadata(q, q.rollback)
if q.rollback == nil {
e.Notify(LevelError, err.Error())
}
return
}
if q.retry == nil {
return
}
r := q.retry
q.retry = nil
d, restore := e.Docs.AssetDocument(r.asset), false
if d == nil {
d, restore = r.doc, true
}
if err := e.queueMetadata(d, restore); err != nil {
if restore {
err = e.restoreSavedMetadata(q, err)
}
e.Notify(LevelError, err.Error())
}
}
// If reimporting a discarded document fails, release the entire private texture generation and restore the pipeline's last validated artifacts.
// Never change source or dirty journals; partial rollback loses private VFS dependencies.
func (e *Editor) restoreSavedMetadata(q *metadataCoordinator, cause error) error {
device, lost := e.metadataDevice()
deferRetry := func() {
q.rollback = cause
q.rollbackDevice = device
q.rollbackAfter = e.Now() + metadataRestoreRetryDelay
}
if lost {
deferRetry()
return fmt.Errorf("discarded metadata will restore saved artifacts when the graphics context is available: %w", cause)
}
batch := &pipeline.Batch{}
idx := e.Pipeline.Index()
idx.Each(func(entry *asset.Entry) {
if entry.Missing {
return
}
switch entry.Kind {
case asset.ArtTexture, asset.ArtSpriteRegion, asset.ArtAtlasPage, asset.ArtAnim:
batch.Imported = append(batch.Imported, *entry)
}
})
for _, id := range q.published {
if _, exists := idx.Lookup(id); !exists {
batch.Removed = append(batch.Removed, id)
}
}
if err := e.applyBatch(batch, e.Pipeline.Artifacts()); err != nil {
deferRetry()
return fmt.Errorf("discarded metadata could not restore saved artifacts: %v; %w", cause, err)
}
q.rollback = nil
q.retry = nil
q.files = nil
clear(q.working)
clear(q.sourceWorking)
clear(q.sourceVersions)
q.published = q.published[:0]
message := fmt.Sprintf("working texture previews returned to the last saved artifacts after a discarded document failed to restore: %v; unsaved document edits are preserved", cause)
for _, d := range e.Docs.AssetDocuments() {
if d.Metadata != nil {
d.WorkingPreview.Pending = false
d.WorkingPreview.Applied = 0
d.WorkingPreview.Error = message
}
}
return fmt.Errorf("%s", message)
}
// CloseAssetPreviews cancels publication before engine shutdown drains jobs.
// Immutable CPU work may finish, but it cannot touch a closed device/document.
func (e *Editor) CloseAssetPreviews() {
if q := e.metadata; q != nil {
q.closed = true
clear(q.queue)
q.queue = nil
clear(q.tracked)
clear(q.working)
clear(q.sourceWorking)
clear(q.sourceVersions)
q.published = nil
q.retry = nil
q.rollback = nil
q.files = nil
if q.unsubscribe != nil {
q.unsubscribe()
q.unsubscribe = nil
}
}
}
func (e *Editor) SaveAsset(d *document.AssetDocument) error {
if e.Docs == nil {
return fmt.Errorf("document manager is unavailable")
}
return e.Docs.SaveAsset(d)
}
// ReloadMetadata discards the shared source journal, restores saved runtime textures after Undo/Redo previews, and invalidates candidates captured before Reload.
func (e *Editor) ReloadMetadata(d *document.AssetDocument) error {
if e.Docs == nil || d == nil || e.Docs.AssetDocument(d.Asset) != d || d.Metadata == nil {
return fmt.Errorf("metadata document is no longer open")
}
wasWorking := d.Metadata.SavedEdits > 0 || d.WorkingPreview.Applied != 0 || d.WorkingPreview.Pending
if err := e.Docs.ReloadScript(d.Metadata); err != nil {
return err
}
if wasWorking && (d.Kind == asset.ArtTexture || d.Kind == asset.ArtSpriteRegion) {
return e.ApplyMetadataPreview(d.Asset)
}
return nil
}
func (e *Editor) UndoAsset(d *document.AssetDocument) error { return e.assetHistory(d, false) }
func (e *Editor) RedoAsset(d *document.AssetDocument) error { return e.assetHistory(d, true) }
func (e *Editor) assetHistory(d *document.AssetDocument, redo bool) error {
if d == nil {
return nil
}
if handled, err := structuredAssetHistory(d, redo); handled {
return err
}
var source *document.ScriptDocument
if d.Metadata != nil {
source = d.Metadata
} else {
source = d.Source
}
if source != nil {
changed := false
if redo {
changed = source.Code.Redo()
} else {
changed = source.Code.Undo()
}
if changed && source == d.Metadata && source.SavedEdits > 0 && (d.Kind == asset.ArtTexture || d.Kind == asset.ArtSpriteRegion) {
return e.ApplyMetadataPreview(d.Asset)
}
return nil
}
if d.Scene != nil {
if redo {
return d.Scene.History.Redo()
}
return d.Scene.History.Undo()
}
if redo && d.Binding.Redo != nil {
return d.Binding.Redo()
}
if !redo && d.Binding.Undo != nil {
return d.Binding.Undo()
}
return nil
}