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

159 lines
6.3 KiB
Go

package editor
import (
"bytes"
"fmt"
"image"
"image/png"
"path"
"strconv"
"strings"
"aego/art"
"aego/asset"
"aego/asset/pipeline"
"aego/editor/core/assetops"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/engine/i18n"
)
// This is a separate, explicitly confirmed conversion; the existing whole-PNG
// copy remains available. It cannot transfer the original scene's AssetRefs.
func askPNGFramesArtCopy(ed *context.Editor, doc *document.AssetDocument) error {
if !canCopyPNGToArt(ed, doc) || doc.Metadata == nil {
return fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_no_metadata", "A project PNG with saved slice metadata is required."))
}
pl, docs, project := ed.Pipeline, ed.Docs, ed.Project
sourcePath, sourceID, metadata := doc.Path, doc.Asset, doc.Metadata
beforePNG, err := pl.SourceBytes(sourcePath)
if err != nil {
return err
}
beforeMeta, err := pl.SourceBytes(sourcePath + pipeline.MetaSuffix)
if err != nil {
return err
}
if beforePNG == nil || beforeMeta == nil {
return fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_no_metadata", "A project PNG with saved slice metadata is required."))
}
// Code documents normalize line endings; compare their actual content to the
// saved sidecar without changing either file.
normalized := bytes.ReplaceAll(bytes.ReplaceAll(beforeMeta, []byte("\r\n"), []byte("\n")), []byte("\r"), []byte("\n"))
if metadata.Dirty() || metadata.Conflict || !bytes.Equal(normalized, metadata.Code.Bytes()) {
return fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_save_metadata", "Save or resolve the image metadata before copying its slices."))
}
meta, err := asset.ParseSourceMeta(beforeMeta, sourcePath+pipeline.MetaSuffix)
if err != nil {
return err
}
if meta.ID != sourceID || len(meta.Subs) < 2 {
return fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_no_metadata", "A project PNG with saved slice metadata is required."))
}
cfg, err := png.DecodeConfig(bytes.NewReader(beforePNG))
if err != nil {
return err
}
rects := make([]image.Rectangle, 0, len(meta.Subs))
for _, frame := range meta.Subs {
r := frame.Rect // x, y, width, height
if r[0] < 0 || r[1] < 0 || r[0] >= cfg.Width || r[1] >= cfg.Height || r[2] <= 0 || r[3] <= 0 || r[2] > cfg.Width-r[0] || r[3] > cfg.Height-r[1] {
return fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_invalid_rect", "A saved slice is empty or outside the PNG."))
}
rects = append(rects, image.Rect(r[0], r[1], r[0]+r[2], r[1]+r[3]))
}
revision := metadata.Code.Rev()
current := func() bool {
return ed.Pipeline == pl && ed.Docs == docs && ed.Project == project &&
docs.AssetDocument(sourceID) == doc && doc.Path == sourcePath && doc.Metadata == metadata &&
!metadata.Dirty() && !metadata.Conflict && metadata.Code.Rev() == revision && bytes.Equal(metadata.Code.Bytes(), normalized)
}
stem := strings.TrimSuffix(path.Base(sourcePath), path.Ext(sourcePath)) + "-animation"
fail := func(err error) { ed.Notify(context.LevelError, err.Error()) }
submit := func(values []string) {
if len(values) != 2 {
return
}
if !current() {
fail(fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_changed", "The project, image document or its metadata changed; start the copy again.")))
return
}
target := strings.TrimSpace(values[0])
if !strings.EqualFold(path.Ext(target), art.SourceExtension) {
fail(fmt.Errorf("image: editable copy must use .aeart"))
return
}
duration, err := strconv.Atoi(strings.TrimSpace(values[1]))
if err != nil || duration < 1 || duration > 600000 {
fail(fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_invalid_duration", "Frame duration must be an integer from 1 to 600000 ms.")))
return
}
drawing, err := art.ImportPNGFrames(beforePNG, rects, duration, art.DefaultLimits())
if err != nil {
fail(err)
return
}
defer drawing.Close()
encoded, err := drawing.Encode()
if err != nil {
fail(err)
return
}
if !current() {
fail(fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_changed", "The project, image document or its metadata changed; start the copy again.")))
return
}
edits, err := newArtCopyEdits(pl, target, encoded)
if err != nil {
fail(err)
return
}
// Strict guards keep both original files unchanged even if another
// importer tries to normalize the PNG sidecar during this operation.
edits = append(edits,
pipeline.SourceEdit{Path: sourcePath, Before: beforePNG, After: beforePNG, ReadOnlyGuard: true},
pipeline.SourceEdit{Path: sourcePath + pipeline.MetaSuffix, Before: beforeMeta, After: beforeMeta, ReadOnlyGuard: true})
batch, err := pl.ApplySources(edits, false)
if err != nil {
fail(err)
return
}
ed.ApplyBatch(batch)
id, ok := pl.Lookup(target)
if !ok {
fail(fmt.Errorf("image: editable copy is not in the asset index"))
return
}
if _, err := ed.OpenAsset(id); err != nil {
fail(err)
}
}
// A full confirmation is separate from the path prompt: Confirm wraps and
// scrolls long translated messages, whereas prompt labels are one line.
ed.Ask(context.Question{
Title: i18n.Tr("editor.art.png_frames_copy", "Create animation from PNG slices…"),
Message: i18n.Tr("editor.art.png_frames_notice", "Creates a separate .aeart from saved slices in metadata order. Choose one duration for all frames. Only color pixels are copied: frame names, IDs, pivots, normal/height/emission channels, materials and existing animations are not transferred. Original PNG, metadata and scene references stay unchanged; assign the new resources explicitly."),
Buttons: []string{i18n.Tr("editor.shell.continue", "Continue"), i18n.Tr("editor.art.cancel", "Cancel")},
Then: func(choice int) {
if choice != 0 {
return
}
if !current() {
fail(fmt.Errorf("%s", i18n.Tr("editor.art.png_frames_changed", "The project, image document or its metadata changed; start the copy again.")))
return
}
ed.Ask(context.Question{
Title: i18n.Tr("editor.art.png_frames_copy", "Create animation from PNG slices…"),
Fields: []string{
i18n.Tr("editor.art.source_path", "Source path"),
i18n.Tr("editor.art.png_frames_duration", "Frame duration (ms)"),
},
Values: []string{assetops.UniqueFile(ed, path.Dir(sourcePath), stem, art.SourceExtension), "100"},
Buttons: []string{i18n.Tr("editor.art.create", "Create"), i18n.Tr("editor.art.cancel", "Cancel")},
Submit: submit,
})
},
})
return nil
}