472 lines
14 KiB
Go
472 lines
14 KiB
Go
package artedit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"os"
|
|
|
|
"aego/art"
|
|
"aego/editor/core/document"
|
|
"aego/editor/core/history"
|
|
"aego/engine/i18n"
|
|
)
|
|
|
|
// Document is owned by AssetBinding.Model. View panels, Inspector and agents
|
|
// resolve this same object, including unsaved pixels, instead of reopening disk.
|
|
// Session and History calls are serialized on Aego's UI/mutation thread.
|
|
type Document struct {
|
|
Asset *document.AssetDocument
|
|
Session *art.Session
|
|
History *history.History
|
|
manager *document.Manager
|
|
source *document.BinarySource
|
|
limits art.Limits
|
|
closed bool
|
|
Draft bool
|
|
recoveryHash string
|
|
recoveryCheck func() error
|
|
PendingRecovery bool
|
|
previewDocument *art.Document
|
|
previewArtRevision, previewRevision uint64
|
|
}
|
|
|
|
func Bind(m *document.Manager, d *document.AssetDocument) (*Document, error) {
|
|
if m == nil || d == nil {
|
|
return nil, fmt.Errorf("art: document manager and asset are required")
|
|
}
|
|
if b, ok := d.Binding.Model.(*Document); ok && !b.closed {
|
|
return b, nil
|
|
}
|
|
lim := art.DefaultLimits()
|
|
source, data, err := m.OpenBinarySource(d, lim.MaxFileBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pixels, err := art.Decode(data, lim)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return bindPixels(m, d, source, pixels, "", nil)
|
|
}
|
|
|
|
// BindImported takes ownership of pixels, including on error. The original
|
|
// source is only a baseline for recovery/conflict checks, never a save target.
|
|
// inputHash covers all importer inputs; verify guards them before recovery.
|
|
func BindImported(m *document.Manager, d *document.AssetDocument, pixels *art.Document, expected []byte, inputHash string, verify func() error) (*Document, error) {
|
|
if pixels == nil {
|
|
return nil, fmt.Errorf("art: imported pixels are required")
|
|
}
|
|
if m == nil || d == nil || d.Binding.Model != nil || inputHash == "" || verify == nil {
|
|
pixels.Close()
|
|
return nil, fmt.Errorf("art: invalid imported document binding")
|
|
}
|
|
source, data, err := m.OpenBinarySource(d, art.DefaultLimits().MaxFileBytes)
|
|
if err != nil || !bytes.Equal(data, expected) {
|
|
pixels.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("art: source changed during import")
|
|
}
|
|
if err := verify(); err != nil {
|
|
pixels.Close()
|
|
return nil, err
|
|
}
|
|
return bindPixels(m, d, source, pixels, inputHash, verify)
|
|
}
|
|
|
|
// BindNew takes ownership of an in-memory document. Its stable recovery key
|
|
// and read-only validation belong to the host; there is no disk source to read
|
|
// or overwrite before an explicit first Save.
|
|
func BindNew(m *document.Manager, d *document.AssetDocument, pixels *art.Document, recoveryKey string, verify func() error) (*Document, error) {
|
|
if pixels == nil {
|
|
return nil, fmt.Errorf("art: new pixels are required")
|
|
}
|
|
if m == nil || d == nil || !d.Transient || m.AssetDocument(d.Asset) != d || d.Binding.Model != nil || recoveryKey == "" || verify == nil {
|
|
pixels.Close()
|
|
return nil, fmt.Errorf("art: invalid new document binding")
|
|
}
|
|
if err := verify(); err != nil {
|
|
pixels.Close()
|
|
return nil, err
|
|
}
|
|
return bindPixels(m, d, nil, pixels, recoveryKey, verify)
|
|
}
|
|
|
|
func bindPixels(m *document.Manager, d *document.AssetDocument, source *document.BinarySource, pixels *art.Document, inputHash string, verify func() error) (*Document, error) {
|
|
lim := art.DefaultLimits()
|
|
b := &Document{Asset: d, manager: m, source: source, limits: lim, Draft: inputHash != "", recoveryHash: inputHash, recoveryCheck: verify}
|
|
var err error
|
|
if err = b.install(pixels, false); err != nil {
|
|
pixels.Close()
|
|
return nil, err
|
|
}
|
|
if _, recoveryErr := m.ReadAssetRecovery(d, lim.MaxFileBytes*2); recoveryErr == nil {
|
|
b.PendingRecovery = true
|
|
} else if !errors.Is(recoveryErr, os.ErrNotExist) {
|
|
b.Close()
|
|
return nil, recoveryErr
|
|
}
|
|
b.attachBinding()
|
|
return b, nil
|
|
}
|
|
|
|
func (b *Document) attachBinding() {
|
|
d := b.Asset
|
|
d.Binding = document.AssetBinding{
|
|
Model: b,
|
|
Actions: b.editActions(),
|
|
Dirty: func() bool { return b.Session.Info().Dirty },
|
|
Save: b.Save, Undo: b.SessionUndo, Redo: b.SessionRedo,
|
|
CanUndo: func() bool { return b.History.CanUndo() || b.Session.Info().Gesture },
|
|
CanRedo: func() bool { return b.History.CanRedo() },
|
|
Close: b.Close,
|
|
Recovery: b.Recovery,
|
|
PendingRecovery: func() bool { return b.PendingRecovery },
|
|
}
|
|
if !b.Draft {
|
|
d.Binding.PreviewRevision = b.PreviewRevision
|
|
d.Binding.PreviewSnapshot = b.PreviewSnapshot
|
|
d.Binding.PreviewCurrent = b.PreviewCurrent
|
|
d.Binding.PreviewSource = func() []byte { data, _, _ := b.PreviewSnapshot(); return data }
|
|
}
|
|
}
|
|
|
|
// RebindSaved moves the same session and history to a newly published source.
|
|
// It does not write or remove any file. Callers retain the old binding if any
|
|
// guard fails, even if the preceding transaction already published a copy.
|
|
func (b *Document) RebindSaved(dst *document.AssetDocument, snapshot *art.Snapshot) error {
|
|
if b.closed || !b.Draft || b.PendingRecovery || b.Session.Info().Gesture || dst == nil || b.manager.AssetDocument(dst.Asset) != dst || dst.Binding.Model != nil || !dst.Capabilities.Has(document.EditSource) {
|
|
return fmt.Errorf("art: cannot transfer the imported drawing to this source")
|
|
}
|
|
if _, err := b.manager.ReadAssetRecovery(dst, b.limits.MaxFileBytes*2); err == nil {
|
|
return fmt.Errorf("art: the destination has a pending recovery")
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
want, err := snapshot.Encode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
source, data, err := b.manager.OpenBinarySource(dst, b.limits.MaxFileBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytes.Equal(data, want) {
|
|
return fmt.Errorf("art: the new source changed before the drawing was transferred")
|
|
}
|
|
if err := b.Session.Document.Publish(snapshot, source.Check); err != nil {
|
|
return err
|
|
}
|
|
if err := b.Session.MarkSnapshotSaved(snapshot); err != nil {
|
|
return err
|
|
}
|
|
old := b.Asset
|
|
old.Binding = document.AssetBinding{}
|
|
b.Asset, b.source, b.Draft, b.recoveryHash, b.recoveryCheck = dst, source, false, "", nil
|
|
b.attachBinding()
|
|
b.History.MarkSaved()
|
|
return nil
|
|
}
|
|
|
|
func (b *Document) editActions() map[document.EditAction]func() error {
|
|
actions := make(map[document.EditAction]func() error)
|
|
for _, action := range []document.EditAction{document.ActionCopy, document.ActionCut, document.ActionPaste, document.ActionDelete, document.ActionSelectAll} {
|
|
actions[action] = func() error {
|
|
if b.closed {
|
|
return art.ErrClosed
|
|
}
|
|
if action == document.ActionSelectAll {
|
|
return b.Session.SetSelection("all", nil, 0)
|
|
}
|
|
if action == document.ActionCopy {
|
|
return b.Session.Copy()
|
|
}
|
|
if err := b.checkEdit(); err != nil {
|
|
return err
|
|
}
|
|
if action == document.ActionPaste {
|
|
at := image.Point{}
|
|
if b.Session.Selection != nil {
|
|
at = b.Session.Selection.Bounds().Min
|
|
}
|
|
return b.Session.Paste(at)
|
|
}
|
|
if action == document.ActionCut {
|
|
if err := b.Session.Copy(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err := b.Execute(context.Background(), art.Operation{Kind: "clear"})
|
|
return err
|
|
}
|
|
}
|
|
return actions
|
|
}
|
|
|
|
func (b *Document) install(d *art.Document, dirty bool) error {
|
|
h := history.New(nil)
|
|
h.SetLimit(b.limits.MaxHistorySteps)
|
|
s, err := art.NewSession(d, &historyHost{history: h, maxBytes: b.limits.MaxHistoryBytes})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.Session != nil {
|
|
// Recovery replaces the pixel document, not the user's navigation.
|
|
// A layer or frame may legitimately have been deleted in the recovered
|
|
// copy; retain each still-valid selection independently and otherwise
|
|
// keep the new session's safe default.
|
|
_ = s.Select(b.Session.Layer, "", "")
|
|
_ = s.Select("", b.Session.Frame, "")
|
|
_ = s.Select("", "", b.Session.Channel)
|
|
b.Session.CancelGesture()
|
|
b.Session.Document.Close()
|
|
b.History.Clear()
|
|
}
|
|
b.Session, b.History = s, h
|
|
if dirty {
|
|
s.MarkUnsaved()
|
|
} else {
|
|
h.MarkSaved()
|
|
}
|
|
return nil
|
|
}
|
|
func (b *Document) checkWritable() error {
|
|
if b.closed {
|
|
return art.ErrClosed
|
|
}
|
|
if !b.Asset.Capabilities.Has(document.EditSource) {
|
|
return fmt.Errorf("art: source is read-only")
|
|
}
|
|
return nil
|
|
}
|
|
func (b *Document) checkEdit() error {
|
|
if err := b.checkWritable(); err != nil {
|
|
return err
|
|
}
|
|
if b.PendingRecovery {
|
|
return fmt.Errorf("%s", i18n.Tr("editor.art.recovery_pending_edit", "Recover or discard the previous recovery before editing this drawing."))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckDraftInputs is read-only. It allows other authoring workflows to reject
|
|
// a draft whose original image, slicing or surface inputs have changed.
|
|
func (b *Document) CheckDraftInputs() error {
|
|
if b.closed {
|
|
return art.ErrClosed
|
|
}
|
|
if b.Draft && b.recoveryCheck != nil {
|
|
return b.recoveryCheck()
|
|
}
|
|
return nil
|
|
}
|
|
func (b *Document) Save() error {
|
|
if err := b.checkEdit(); err != nil {
|
|
return err
|
|
}
|
|
if b.Draft {
|
|
return fmt.Errorf("art: an imported image needs an explicit destination before saving")
|
|
}
|
|
if b.Session.Info().Gesture {
|
|
return fmt.Errorf("art: finish or cancel the current gesture before saving")
|
|
}
|
|
snapshot, err := b.Session.Document.Snapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := snapshot.Encode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = b.Session.Document.Publish(snapshot, func() error { return b.source.Commit(data) }); err != nil {
|
|
return err
|
|
}
|
|
if err = b.Session.MarkSnapshotSaved(snapshot); err != nil {
|
|
return err
|
|
}
|
|
b.History.MarkSaved()
|
|
// Import publication must not be suppressed by recovery-file cleanup failure.
|
|
b.source.NotifySaved()
|
|
b.PendingRecovery = false
|
|
return b.manager.ClearOwnedAssetRecovery(b.Asset)
|
|
}
|
|
func (b *Document) SessionUndo() error {
|
|
if err := b.checkEdit(); err != nil {
|
|
return err
|
|
}
|
|
return b.Session.Undo()
|
|
}
|
|
func (b *Document) SessionRedo() error {
|
|
if err := b.checkEdit(); err != nil {
|
|
return err
|
|
}
|
|
return b.Session.Redo()
|
|
}
|
|
func (b *Document) Close() {
|
|
if b.closed {
|
|
return
|
|
}
|
|
b.closed = true
|
|
if b.Session != nil {
|
|
b.Session.ClearPreview()
|
|
}
|
|
b.Session.CancelGesture()
|
|
b.Session.Document.Close()
|
|
b.History.Clear()
|
|
}
|
|
func (b *Document) Execute(ctx context.Context, op art.Operation) (art.ID, error) {
|
|
if err := b.checkEdit(); err != nil {
|
|
return "", err
|
|
}
|
|
return b.Session.Execute(ctx, op)
|
|
}
|
|
|
|
// Proposal is prepared without mutation. Accept must be routed through the
|
|
// existing confirmation/Mutation Service; it is not an implicit auto-approval.
|
|
type Proposal struct {
|
|
owner *Document
|
|
change *art.Change
|
|
Revision uint64
|
|
Results []art.ID
|
|
}
|
|
|
|
func (b *Document) Propose(ctx context.Context, revision uint64, label string, ops []art.Operation) (*Proposal, error) {
|
|
if err := b.checkEdit(); err != nil {
|
|
return nil, err
|
|
}
|
|
if b.Session.Info().Gesture {
|
|
return nil, fmt.Errorf("art: a user gesture is in progress")
|
|
}
|
|
change, results, err := b.Session.Document.Propose(ctx, revision, label, ops)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Proposal{owner: b, change: change, Revision: revision, Results: results}, nil
|
|
}
|
|
func (b *Document) Accept(p *Proposal) error {
|
|
if err := b.checkEdit(); err != nil {
|
|
return err
|
|
}
|
|
if p == nil || p.owner != b || p.Revision != b.Session.Document.Revision() {
|
|
return art.ErrConflict
|
|
}
|
|
if b.Session.Info().Gesture {
|
|
return fmt.Errorf("art: a user gesture is in progress")
|
|
}
|
|
return b.Session.History.Exec(p.change)
|
|
}
|
|
func (b *Document) Capture(frame art.ID, channel art.Channel) (*image.NRGBA, uint64, error) {
|
|
if b.closed {
|
|
return nil, 0, art.ErrClosed
|
|
}
|
|
s, err := b.Session.Document.Snapshot()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
pixels, err := s.Render(frame, art.RenderOptions{Channel: channel})
|
|
return pixels, s.Revision(), err
|
|
}
|
|
|
|
type recovery struct {
|
|
Version int `json:"version"`
|
|
SourceSHA string `json:"sourceSHA256"`
|
|
Data []byte `json:"data"`
|
|
}
|
|
|
|
func (b *Document) Recovery() ([]byte, error) {
|
|
if b.closed {
|
|
return nil, art.ErrClosed
|
|
}
|
|
if b.PendingRecovery {
|
|
return nil, fmt.Errorf("%s", i18n.Tr("editor.art.recovery_pending_edit", "Recover or discard the previous recovery before editing this drawing."))
|
|
}
|
|
s, err := b.Session.Document.Snapshot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := s.Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash := b.recoveryHash
|
|
if hash == "" {
|
|
if b.source == nil {
|
|
return nil, fmt.Errorf("art: recovery has no source identity")
|
|
}
|
|
hash = b.source.SHA256()
|
|
}
|
|
return json.Marshal(recovery{1, hash, data})
|
|
}
|
|
|
|
// Recover rejects disk conflicts; the UI must explicitly ask before calling it.
|
|
func (b *Document) Recover() error {
|
|
if err := b.checkWritable(); err != nil {
|
|
return err
|
|
}
|
|
data, err := b.manager.ReadAssetRecovery(b.Asset, b.limits.MaxFileBytes*2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var r recovery
|
|
if err = json.Unmarshal(data, &r); err != nil {
|
|
return err
|
|
}
|
|
hash := b.recoveryHash
|
|
if hash == "" {
|
|
if b.source == nil {
|
|
return fmt.Errorf("art: recovery has no source identity")
|
|
}
|
|
hash = b.source.SHA256()
|
|
}
|
|
if r.Version != 1 || r.SourceSHA != hash {
|
|
return fmt.Errorf("art: recovery conflicts with source on disk")
|
|
}
|
|
if b.source != nil {
|
|
if err = b.source.Check(); err != nil {
|
|
return err
|
|
}
|
|
} else if !b.Draft || !b.Asset.Transient {
|
|
return fmt.Errorf("art: recovery source is unavailable")
|
|
}
|
|
if b.recoveryCheck != nil {
|
|
if err := b.recoveryCheck(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
d, err := art.Decode(r.Data, b.limits)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = b.install(d, true); err != nil {
|
|
d.Close()
|
|
return err
|
|
}
|
|
b.manager.TrackRestoredAssetRecovery(b.Asset, data)
|
|
b.PendingRecovery = false
|
|
return nil
|
|
}
|
|
|
|
// Bytes is the retained COW estimate used by the existing agent proposal queue.
|
|
func (p *Proposal) Bytes() int64 {
|
|
if p == nil || p.change == nil {
|
|
return 0
|
|
}
|
|
return p.change.Bytes()
|
|
}
|
|
|
|
func (b *Document) DiscardRecovery() error {
|
|
if b.closed {
|
|
return art.ErrClosed
|
|
}
|
|
if err := b.manager.ClearAssetRecovery(b.Asset); err != nil {
|
|
return err
|
|
}
|
|
b.PendingRecovery = false
|
|
return nil
|
|
}
|