Fix animgallery rendering bugs and mitigate AI-upscale frame flicker
build / build (push) Successful in 2m52s
build / build (push) Successful in 2m52s
- writeGIF now pads every frame to one shared canvas instead of writing each at its own (possibly storage-cropped) size - GIF disposal rules were showing a previous frame's stale pixels through the uncovered border of a smaller frame, bleeding content between frames in the gallery. - decodeFrames/decodeViewFrames/decodeAnimationFrames now always hand bitmap.DecodeReferenced a zero buffer instead of the previous frame/block's real pixels. The game's actual RSD8 decoder treats a "skip" run as literal zero, with no mechanism to carry a reference frame's pixels forward (confirmed against Night Dive's published source), and hacked's own multi-block Bitmap preview decodes the same way. The previous behavior ghosted a previous frame's stale content into the current frame's skip-run regions, in both the gallery and the AI upscaler's source input. - index.html now shows a labeled per-frame filmstrip next to each animated GIF, and a base-vs-this-build side-by-side comparison for every resource a mod overrides. - Gallery GIFs play back at a third of real speed by default, for easier frame-by-frame review. - modding/mods/*/bitmaps/*.json gains considerPreviousFrames: a per-resource list that runs a temporal-consistency pass (median across the animation's own frames) on independently-upscaled frames to reduce per-frame AI flicker. Applied to 0x7B1 in upscale_medbot. - Drop the rebecca-test example mod.
This commit is contained in:
+4
-1
@@ -18,7 +18,9 @@
|
|||||||
# animgallery_mods - same, but with every built mod under dist/mods/
|
# animgallery_mods - same, but with every built mod under dist/mods/
|
||||||
# layered on top (later wins), in build/gallery-mods/ -
|
# layered on top (later wins), in build/gallery-mods/ -
|
||||||
# shows what a mod actually changes without launching
|
# shows what a mod actually changes without launching
|
||||||
# the game. Run `make mods` first.
|
# the game. Every card for a resource a mod overrides
|
||||||
|
# shows the original alongside it for comparison. Run
|
||||||
|
# `make mods` first.
|
||||||
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
||||||
# the base game, like animgallery_mods, but restricted
|
# the base game, like animgallery_mods, but restricted
|
||||||
# to only the resources a mod actually overrides, in
|
# to only the resources a mod actually overrides, in
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
# Run `make mods` first.
|
# Run `make mods` first.
|
||||||
# (animgallery_base/_mods/_dist all write into this
|
# (animgallery_base/_mods/_dist all write into this
|
||||||
# subproject's own build/, not the repo root's.)
|
# subproject's own build/, not the repo root's.)
|
||||||
|
#
|
||||||
# clean - removes dist/.
|
# clean - removes dist/.
|
||||||
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -225,6 +225,7 @@ exact JSON shape from whatever's checked) produces this format directly:
|
|||||||
| `model` | Which baked-in ONNX model to upscale with - see the table below |
|
| `model` | Which baked-in ONNX model to upscale with - see the table below |
|
||||||
| `scale` | Output size as a multiple of the original (for example `2`) |
|
| `scale` | Output size as a multiple of the original (for example `2`) |
|
||||||
| `resources` | Each entry is `"0x<ID>"` (language `Any`) or `"0x<ID>_<Language>"` (for example `"0x25_German"`), matching `gen_animgallery.sh`'s own id format |
|
| `resources` | Each entry is `"0x<ID>"` (language `Any`) or `"0x<ID>_<Language>"` (for example `"0x25_German"`), matching `gen_animgallery.sh`'s own id format |
|
||||||
|
| `considerPreviousFrames` | Optional, same id format as `resources` - an id here that isn't also in `resources` is simply never applied, not an error. A listed resource with 3+ frames gets a temporal-consistency pass (section 5) that pulls a frame's upscaled pixel back toward the rest of the animation's own consensus wherever it disagrees by more than the real low-res source justifies - a real mitigation for per-frame upscale flicker. Lets one resource opt in without splitting it into its own file or changing `model`/`scale` for the rest. |
|
||||||
|
|
||||||
Available `model` values (baked into `mdlp-image`, see
|
Available `model` values (baked into `mdlp-image`, see
|
||||||
`mdlp-image/upscale_batch.py`):
|
`mdlp-image/upscale_batch.py`):
|
||||||
@@ -315,7 +316,15 @@ same bytes the engine loads, so a problem visible there is a problem in the
|
|||||||
mod, not an artifact of some other preview path. Regenerate it with `-data
|
mod, not an artifact of some other preview path. Regenerate it with `-data
|
||||||
res/assets/ss_ee/data -data dist/mods/<name>/mod` layered over the base
|
res/assets/ss_ee/data -data dist/mods/<name>/mod` layered over the base
|
||||||
data to see the mod's effect, or with just the base `-data` argument to
|
data to see the mod's effect, or with just the base `-data` argument to
|
||||||
check what the unmodified resource looks like for comparison.
|
check what the unmodified resource looks like for comparison. Each card
|
||||||
|
shows the looping GIF plus a static filmstrip of every one of its frames
|
||||||
|
side by side, so one specific frame can be singled out and compared without
|
||||||
|
having to pause the animation by eye. When two or more `-data` directories
|
||||||
|
are layered this way, a card for any resource a later layer actually
|
||||||
|
overrides shows both versions side by side - the first directory's own
|
||||||
|
("base") and the winning one ("this build") - so a mod's effect is always
|
||||||
|
checked against the original it's replacing, not recalled from memory or a
|
||||||
|
separate base-only gallery run.
|
||||||
|
|
||||||
`gen_animgallery.sh` is a from-scratch Go decoder, not the game engine -
|
`gen_animgallery.sh` is a from-scratch Go decoder, not the game engine -
|
||||||
it never exercises the engine's own texture upload/caching code at all.
|
it never exercises the engine's own texture upload/caching code at all.
|
||||||
@@ -346,6 +355,33 @@ resources rather than trusting a handful of spot-checks:
|
|||||||
re-quantization entirely rather than relying on a brightness or
|
re-quantization entirely rather than relying on a brightness or
|
||||||
saturation threshold, which proved too easy for a genuinely
|
saturation threshold, which proved too easy for a genuinely
|
||||||
dark-but-muted pixel to slip past.
|
dark-but-muted pixel to slip past.
|
||||||
|
- **Per-frame hallucination in an animation's upscaled output.**
|
||||||
|
`upscale_batch.py` runs every frame through the ONNX model independently,
|
||||||
|
with no shared state between frames - a well-known limitation of
|
||||||
|
single-image super-resolution. On a low-detail/ambiguous source region
|
||||||
|
the model can guess differently frame to frame, so a detail (an extra
|
||||||
|
claw, a stray highlight) pops in for a few frames and back out, even
|
||||||
|
though the real low-resolution source barely changed there. Review by eye
|
||||||
|
(`make animgallery_mods`/`animgallery_dist`, section 4.3) before trusting
|
||||||
|
a new upscale definition - this is invisible from a single frame or a
|
||||||
|
handful of spot-checks and needs to be checked per-animation, ideally
|
||||||
|
with the GIF slowed down and its base-vs-this-build comparison columns
|
||||||
|
(both features of the gallery itself) rather than played at real speed.
|
||||||
|
|
||||||
|
`considerPreviousFrames` (section 4.1) is a real mitigation, not a
|
||||||
|
different model/scale gamble, for a resource found to have this problem:
|
||||||
|
list just the resources that need it and the rest of the definition keeps
|
||||||
|
its own `model`/`scale` unchanged. For each listed resource with 3+
|
||||||
|
frames, after independent per-frame upscaling, each frame's pixels are
|
||||||
|
compared against the per-pixel median across every frame of the same
|
||||||
|
(cyclic) animation - does the upscaled difference from that consensus
|
||||||
|
exceed what the real low-res source's own difference from its consensus
|
||||||
|
justifies - and pulled to the median where it does, on the same model the
|
||||||
|
rest of the definition already uses. Try it before reaching for a
|
||||||
|
different model or scale. It needs 3+ frames for a meaningful consensus (a
|
||||||
|
2-frame animation has no independent third frame to vote with), and never
|
||||||
|
touches alpha or
|
||||||
|
an already-transparent pixel.
|
||||||
|
|
||||||
`encodeFrames` compresses every frame against an all-zero reference
|
`encodeFrames` compresses every frame against an all-zero reference
|
||||||
instead of diffing against the previous frame's real pixels, because
|
instead of diffing against the previous frame's real pixels, because
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "Mails",
|
|
||||||
"index": 26,
|
|
||||||
"sender": "Rebecca Lansing1",
|
|
||||||
"title": "Rebecca-001",
|
|
||||||
"verboseTextFile": "mail-026.txt"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Listen to me very carefully HACKER. Something has gone terribly wrong on Citadel station. You might be the last survivor. Communications are being jammed. We know that something on board is attempting to harm humanity. The mining laser is being charged for a possible strike against Earth populations. On your level is the office of a Dr. D'Arcy, who may know something about the laser. The office is located near the central hub, on a balcony, with the medical icon clearly visible. When the laser is destroyed, you will have to reach the bridge and stop whatever is responsible for the catastrophe. We believe that the biological labs and security systems have gone haywire, so be careful. If you pull this off, we will be able to get you out alive. Good luck.
|
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
"0x7B6",
|
"0x7B6",
|
||||||
"0x7B7",
|
"0x7B7",
|
||||||
"0x84C"
|
"0x84C"
|
||||||
]
|
],
|
||||||
|
"considerPreviousFrames": ["0x7B1"]
|
||||||
}
|
}
|
||||||
|
|||||||
+274
-62
@@ -1,9 +1,13 @@
|
|||||||
// Command animgallery scans a game data directory for every multi-frame
|
// Command animgallery scans a game data directory for every multi-frame
|
||||||
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||||
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||||
// Shock uses both conventions) and renders each one as an animated GIF plus
|
// Shock uses both conventions) and renders each one as an animated GIF, plus
|
||||||
// an HTML index page, so they can be browsed by eye instead of guessed at
|
// a static filmstrip of every frame side by side (the loop alone doesn't let
|
||||||
// by resource ID.
|
// a specific frame be singled out), into an HTML index page, so they can be
|
||||||
|
// browsed by eye instead of guessed at by resource ID. Layering a mod's own
|
||||||
|
// build output over the base game data (repeated -data) renders the result
|
||||||
|
// as actually played - and for any resource the mod overrides, the base
|
||||||
|
// game's own version alongside it, for a direct comparison.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,7 +16,9 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
|
"image/png"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -36,6 +42,13 @@ type galleryEntry struct {
|
|||||||
height int
|
height int
|
||||||
transparent bool
|
transparent bool
|
||||||
gifName string
|
gifName string
|
||||||
|
filmstripName string
|
||||||
|
// baseGifName/baseFilmstripName are set only when this resource's
|
||||||
|
// winning layer isn't the first -data directory - the same content
|
||||||
|
// rendered from that first (base) directory alone, for a side-by-side
|
||||||
|
// comparison against what a later layer (a mod) overrides it with.
|
||||||
|
baseGifName string
|
||||||
|
baseFilmstripName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// dataDirs collects repeated -data flags, in the order given - later ones
|
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||||
@@ -167,6 +180,32 @@ func main() {
|
|||||||
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseMod holds only the first -data directory, unlayered - resolving a
|
||||||
|
// resource through it instead of the full mod gives back what a later
|
||||||
|
// layer (a mod) is actually overriding, for a side-by-side comparison on
|
||||||
|
// every entry one of those layers changes. Reloaded independently of the
|
||||||
|
// dirs loop above rather than reusing its ManifestEntry, to keep that
|
||||||
|
// loop's own bookkeeping (sourceFileByKey, ...) untangled from this.
|
||||||
|
var baseMod *world.Mod
|
||||||
|
if len(dirs) > 1 {
|
||||||
|
baseAbsDir, err := filepath.Abs(dirs[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -data %q: %v", dirs[0], err)
|
||||||
|
}
|
||||||
|
baseEntry, err := loadDataDir(baseAbsDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("base-layer comparison disabled: reloading %s: %v", baseAbsDir, err)
|
||||||
|
} else {
|
||||||
|
sort.Slice(baseEntry.Resources, func(i, j int) bool { return baseEntry.Resources[i].ID < baseEntry.Resources[j].ID })
|
||||||
|
candidate := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||||
|
if err := candidate.World().InsertEntry(candidate.World().EntryCount(), baseEntry); err != nil {
|
||||||
|
log.Printf("base-layer comparison disabled: registering %s: %v", baseAbsDir, err)
|
||||||
|
} else {
|
||||||
|
baseMod = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||||
for key := range sourceFileByKey {
|
for key := range sourceFileByKey {
|
||||||
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||||
@@ -187,29 +226,31 @@ func main() {
|
|||||||
// Re-resolved through the layered mod, not the raw per-directory
|
// Re-resolved through the layered mod, not the raw per-directory
|
||||||
// viewer, so an overriding mod's version wins here exactly like it
|
// viewer, so an overriding mod's version wins here exactly like it
|
||||||
// would for the running game.
|
// would for the running game.
|
||||||
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
entry, ok, err := renderForMod(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "", *minFrames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("skipping %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
// A later layer wins this key over the first (base) -data directory
|
||||||
case view.ContentType() == resource.Animation:
|
// - also render the base directory's own version of it, so the two
|
||||||
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
// can be shown side by side instead of only the result actually
|
||||||
|
// in use.
|
||||||
|
if baseMod != nil && sourceDirIndexByKey[key] != 0 {
|
||||||
|
baseEntry, ok, err := renderForMod(baseMod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "_base", *minFrames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
log.Printf("skipping base comparison for %s (%s): %v", key.ID, sourceFile, err)
|
||||||
continue
|
} else if ok {
|
||||||
|
entry.baseGifName = baseEntry.gifName
|
||||||
|
entry.baseFilmstripName = baseEntry.filmstripName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entries = append(entries, entry)
|
|
||||||
|
|
||||||
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
|
||||||
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
if entries[i].id != entries[j].id {
|
if entries[i].id != entries[j].id {
|
||||||
@@ -228,30 +269,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeFrames reads every block of a compound bitmap resource as a
|
// decodeFrames reads every block of a compound bitmap resource as a
|
||||||
// sequence of frames, each one RLE-diffed against the previous - the same
|
// sequence of frames. Each block's RLE data can carry a "skip" run, which
|
||||||
// chaining hacked's own editor/animations/View.go uses when importing a
|
// bitmap.DecodeReferenced leaves untouched in whatever buffer it's handed -
|
||||||
// GIF, done here in reverse (decode instead of encode).
|
// the provider function below always hands it a fresh zero buffer, never a
|
||||||
|
// previous block's pixels, because the game's actual RSD8 decoder
|
||||||
|
// (engine/src/Libraries/2D/Source/RSD/{rsdcvt.c,RSDUnpack.c}, see
|
||||||
|
// upscale.go's encodeFrames) treats a skip run as literal zero for every
|
||||||
|
// resource type including critter sprites, with no mechanism to carry a
|
||||||
|
// reference image's pixels forward. hacked's own multi-block Bitmap preview
|
||||||
|
// (editor/bitmaps/View.go) matches that: it always calls Texture() with no
|
||||||
|
// reference. A non-zero buffer here would ghost a previous block's stale
|
||||||
|
// pixels into the current block's skip-run regions.
|
||||||
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
count := view.BlockCount()
|
count := view.BlockCount()
|
||||||
frames := make([]*bitmap.Bitmap, 0, count)
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
var prevPixels []byte
|
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
reader, err := view.Block(i)
|
reader, err := view.Block(i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -265,34 +307,55 @@ func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
|||||||
return pal.ColorPalette(false)
|
return pal.ColorPalette(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
// gallerySpeedFactor slows every gallery GIF down to a fraction of real
|
||||||
|
// in-game speed, since the real speed is often too fast to inspect a frame's
|
||||||
|
// detail by eye (the point of this gallery).
|
||||||
|
const gallerySpeedFactor = 3
|
||||||
|
|
||||||
|
// writeGIF composes every frame onto one shared canvas, sized to the larger
|
||||||
|
// of minWidth/minHeight (the format's own declared animation extent, 0 if
|
||||||
|
// unknown) and the largest individual frame, and places each frame's pixels
|
||||||
|
// at its top-left corner. A frame smaller than the canvas is a storage-level
|
||||||
|
// crop, not a smaller sprite (System Shock animation frames can be
|
||||||
|
// RLE-delta-encoded against the previous frame's unchanged pixels, see
|
||||||
|
// bitmap.DecodeReferenced), so every encoded GIF frame must still cover the
|
||||||
|
// whole canvas itself - otherwise GIF disposal rules leave the uncovered
|
||||||
|
// border showing whatever the previous frame drew there, bleeding stale
|
||||||
|
// content from frame to frame (visible in an external GIF viewer; System
|
||||||
|
// Shock itself always swaps in a complete texture per frame, so this never
|
||||||
|
// shows up in-game).
|
||||||
|
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int, minWidth, minHeight int) error {
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
maxWidth, maxHeight := 0, 0
|
canvasWidth, canvasHeight := minWidth, minHeight
|
||||||
for _, bmp := range frames {
|
for _, bmp := range frames {
|
||||||
if int(bmp.Header.Width) > maxWidth {
|
if int(bmp.Header.Width) > canvasWidth {
|
||||||
maxWidth = int(bmp.Header.Width)
|
canvasWidth = int(bmp.Header.Width)
|
||||||
}
|
}
|
||||||
if int(bmp.Header.Height) > maxHeight {
|
if int(bmp.Header.Height) > canvasHeight {
|
||||||
maxHeight = int(bmp.Header.Height)
|
canvasHeight = int(bmp.Header.Height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||||
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||||
// zero value) means play once, which is why these looped only once
|
// zero value) means play once.
|
||||||
// before this fix.
|
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: canvasWidth, Height: canvasHeight}}
|
||||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
canvasRect := image.Rect(0, 0, canvasWidth, canvasHeight)
|
||||||
for i, bmp := range frames {
|
for i, bmp := range frames {
|
||||||
colorPal := paletteFor(bmp, fallbackPal)
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
img := image.NewPaletted(canvasRect, colorPal)
|
||||||
img := image.NewPaletted(rect, colorPal)
|
frameWidth, frameHeight := int(bmp.Header.Width), int(bmp.Header.Height)
|
||||||
if len(bmp.Pixels) == len(img.Pix) {
|
if len(bmp.Pixels) == frameWidth*frameHeight {
|
||||||
copy(img.Pix, bmp.Pixels)
|
for y := 0; y < frameHeight; y++ {
|
||||||
|
srcOffset := y * frameWidth
|
||||||
|
dstOffset := img.PixOffset(0, y)
|
||||||
|
copy(img.Pix[dstOffset:dstOffset+frameWidth], bmp.Pixels[srcOffset:srcOffset+frameWidth])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.Image = append(out.Image, img)
|
out.Image = append(out.Image, img)
|
||||||
out.Delay = append(out.Delay, delays[i])
|
out.Delay = append(out.Delay, delays[i])
|
||||||
@@ -300,20 +363,114 @@ func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette,
|
|||||||
return gif.EncodeAll(f, &out)
|
return gif.EncodeAll(f, &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// glyph5x7 is a minimal 5-column x 7-row bitmap font, hand-drawn rather than
|
||||||
|
// pulled from golang.org/x/image/font/basicfont: that package is only a
|
||||||
|
// test-only transitive dependency of hacked, never actually fetched into
|
||||||
|
// mdlp-image's offline module cache, so using it here would need a
|
||||||
|
// Dockerfile change and an image rebuild for a handful of label glyphs.
|
||||||
|
// Only the characters writeFilmstrip's "FRAME N" labels ever use are
|
||||||
|
// defined; '#' is a lit pixel, anything else is blank.
|
||||||
|
var glyph5x7 = map[rune][7]string{
|
||||||
|
' ': {" ", " ", " ", " ", " ", " ", " "},
|
||||||
|
'0': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
|
||||||
|
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
|
||||||
|
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
|
||||||
|
'3': {".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."},
|
||||||
|
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
|
||||||
|
'5': {"#####", "#....", "####.", "....#", "....#", "#...#", ".###."},
|
||||||
|
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
|
||||||
|
'7': {"#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."},
|
||||||
|
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
|
||||||
|
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
|
||||||
|
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
|
||||||
|
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
|
||||||
|
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
|
||||||
|
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
|
||||||
|
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawLabel renders s (upper-cased, unknown runes blank) at (x, y) in a
|
||||||
|
// fixed 5x7 pixel font, one pixel of gap between characters - small and
|
||||||
|
// legible enough for a "FRAME N" strip label, with no font-rendering
|
||||||
|
// dependency.
|
||||||
|
func drawLabel(canvas draw.Image, x, y int, s string, c color.Color) {
|
||||||
|
col := x
|
||||||
|
for _, r := range strings.ToUpper(s) {
|
||||||
|
glyph, ok := glyph5x7[r]
|
||||||
|
if !ok {
|
||||||
|
glyph = glyph5x7[' ']
|
||||||
|
}
|
||||||
|
for row, line := range glyph {
|
||||||
|
for bit, ch := range line {
|
||||||
|
if ch == '#' {
|
||||||
|
canvas.Set(col+bit, y+row, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col += 6 // 5px glyph + 1px gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFilmstrip renders every frame side by side at native size, each
|
||||||
|
// labeled "frame N", separated by a thin gap, as a static complement to the
|
||||||
|
// looping GIF - the index page otherwise only shows the loop in motion,
|
||||||
|
// never all of an animation's frames side by side for comparison, or which
|
||||||
|
// index a given frame is.
|
||||||
|
func writeFilmstrip(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
const gap = 2
|
||||||
|
const labelHeight = 14
|
||||||
|
totalWidth, maxHeight := -gap, 0
|
||||||
|
for _, bmp := range frames {
|
||||||
|
totalWidth += int(bmp.Header.Width) + gap
|
||||||
|
if int(bmp.Header.Height) > maxHeight {
|
||||||
|
maxHeight = int(bmp.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, totalWidth, labelHeight+maxHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
|
||||||
|
labelColor := color.RGBA{200, 200, 200, 255}
|
||||||
|
|
||||||
|
x := 0
|
||||||
|
for i, bmp := range frames {
|
||||||
|
drawLabel(canvas, x+1, 2, fmt.Sprintf("FRAME %d", i), labelColor)
|
||||||
|
|
||||||
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
|
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||||
|
img := image.NewPaletted(rect, colorPal)
|
||||||
|
if len(bmp.Pixels) == len(img.Pix) {
|
||||||
|
copy(img.Pix, bmp.Pixels)
|
||||||
|
}
|
||||||
|
dstRect := image.Rect(x, labelHeight, x+int(bmp.Header.Width), labelHeight+int(bmp.Header.Height))
|
||||||
|
draw.Draw(canvas, dstRect, img, image.Point{}, draw.Src)
|
||||||
|
x += int(bmp.Header.Width) + gap
|
||||||
|
}
|
||||||
|
return png.Encode(f, canvas)
|
||||||
|
}
|
||||||
|
|
||||||
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||||
// code (malformed or non-image compound resources can crash rle.Decompress
|
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||||
// rather than return an error) so one bad resource never aborts a scan that
|
// rather than return an error) so one bad resource never aborts a scan that
|
||||||
// is meant to be re-run repeatedly during modding.
|
// is meant to be re-run repeatedly during modding.
|
||||||
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
// suffix distinguishes a base-layer comparison render ("_base") from the
|
||||||
|
// normal, winning-layer one ("") for the same resource ID/language, so the
|
||||||
|
// two don't collide on the same output filenames in outDir.
|
||||||
|
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
|
||||||
frames, err := decodeFrames(view)
|
frames, err := decodeFrames(view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
@@ -323,10 +480,17 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
|
|||||||
}
|
}
|
||||||
delays := make([]int, len(frames))
|
delays := make([]int, len(frames))
|
||||||
for i := range delays {
|
for i := range delays {
|
||||||
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
delays[i] = 10 * gallerySpeedFactor // no explicit timing exists for this raw form
|
||||||
}
|
}
|
||||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
// No separate declared canvas size exists for this content type (unlike
|
||||||
|
// Animation descriptors below) - writeGIF falls back to the largest
|
||||||
|
// individual frame.
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, 0, 0); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
|
||||||
|
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
}
|
}
|
||||||
return galleryEntry{
|
return galleryEntry{
|
||||||
@@ -335,21 +499,24 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
|
|||||||
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
gifName: gifName,
|
gifName: gifName,
|
||||||
|
filmstripName: filmstripName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||||
// for the Animation-descriptor path.
|
// for the Animation-descriptor path.
|
||||||
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
// suffix distinguishes a base-layer comparison render ("_base") from the
|
||||||
|
// normal, winning-layer one ("") - see renderMultiBlockBitmap.
|
||||||
|
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
|
||||||
view, err := mod.LocalizedResources(lang).Select(id)
|
view, err := mod.LocalizedResources(lang).Select(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
@@ -375,7 +542,6 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
|
|
||||||
var frames []*bitmap.Bitmap
|
var frames []*bitmap.Bitmap
|
||||||
var delays []int
|
var delays []int
|
||||||
var prevPixels []byte
|
|
||||||
frameIndex := 0
|
frameIndex := 0
|
||||||
for _, entry := range anim.Entries {
|
for _, entry := range anim.Entries {
|
||||||
for frameIndex <= int(entry.LastFrame) {
|
for frameIndex <= int(entry.LastFrame) {
|
||||||
@@ -388,22 +554,21 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
|
// Always a fresh zero buffer, never a previous frame's real
|
||||||
|
// pixels - see decodeFrames' own comment for why that's what
|
||||||
|
// matches the game's actual RSD8 decoder (and hacked's own
|
||||||
|
// bitmap preview) rather than ghosting stale content into a
|
||||||
|
// skip run.
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
delay := int(entry.FrameTime) / 10
|
delay := int(entry.FrameTime) / 10 * gallerySpeedFactor
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
delay = 10
|
delay = 10 * gallerySpeedFactor
|
||||||
}
|
}
|
||||||
delays = append(delays, delay)
|
delays = append(delays, delay)
|
||||||
frameIndex++
|
frameIndex++
|
||||||
@@ -413,8 +578,14 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||||
}
|
}
|
||||||
|
|
||||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
// anim.Width/Height is this format's own declared canvas, which can be
|
||||||
|
// larger than any single frame's stored (possibly delta-cropped) size.
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, int(anim.Width), int(anim.Height)); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
|
||||||
|
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
}
|
}
|
||||||
return galleryEntry{
|
return galleryEntry{
|
||||||
@@ -423,9 +594,32 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
width: int(anim.Width), height: int(anim.Height),
|
width: int(anim.Width), height: int(anim.Height),
|
||||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
gifName: gifName,
|
gifName: gifName,
|
||||||
|
filmstripName: filmstripName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderForMod resolves id/lang through m (a specific layering - the full
|
||||||
|
// mod, or just a base game directory on its own) and renders it, the same
|
||||||
|
// content-type dispatch the main scan uses, so it can also be called a
|
||||||
|
// second time against a base-only Mod to render a comparison. ok is false
|
||||||
|
// (with a nil error) when id/lang isn't an eligible animated resource in m
|
||||||
|
// at all, as opposed to being eligible but failing to decode or render.
|
||||||
|
func renderForMod(m *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string, minFrames int) (entry galleryEntry, ok bool, err error) {
|
||||||
|
view, err := m.LocalizedResources(lang).Select(id)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, false, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case view.ContentType() == resource.Animation:
|
||||||
|
entry, err = safeRenderAnimationDescriptor(m, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
|
return entry, true, err
|
||||||
|
case view.ContentType() == resource.Bitmap && view.BlockCount() >= minFrames:
|
||||||
|
entry, err = safeRenderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
|
return entry, true, err
|
||||||
|
}
|
||||||
|
return galleryEntry{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||||
// name mdlp will need (its own decode path differs: an "Animation"
|
// name mdlp will need (its own decode path differs: an "Animation"
|
||||||
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||||
@@ -460,6 +654,9 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||||
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||||
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||||
|
b.WriteString(".card img.frames{margin-top:4px;max-width:100%}")
|
||||||
|
b.WriteString(".compare{display:flex;gap:10px}")
|
||||||
|
b.WriteString(".compare-label{font-size:11px;color:#8ab;text-transform:uppercase;margin-bottom:2px}")
|
||||||
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||||
b.WriteString(".meta label{cursor:pointer}")
|
b.WriteString(".meta label{cursor:pointer}")
|
||||||
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||||
@@ -507,7 +704,22 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||||
}, " "))
|
}, " "))
|
||||||
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||||
|
if e.baseGifName != "" {
|
||||||
|
b.WriteString("<div class=\"compare\">")
|
||||||
|
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">base</div>")
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.baseGifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every base frame, in order\">", html.EscapeString(e.baseFilmstripName))
|
||||||
|
b.WriteString("</div>")
|
||||||
|
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">this build</div>")
|
||||||
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
|
||||||
|
b.WriteString("</div></div>")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
if e.filmstripName != "" {
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||||
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||||
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||||
|
|||||||
+198
-27
@@ -13,11 +13,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/png"
|
"image/png"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -48,11 +50,23 @@ var languageByName = map[string]resource.Language{
|
|||||||
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
||||||
// set of upscale parameters and every resource to apply them to. Model
|
// set of upscale parameters and every resource to apply them to. Model
|
||||||
// names a .onnx file baked into mdlp-image - see upscale_batch.py for the
|
// names a .onnx file baked into mdlp-image - see upscale_batch.py for the
|
||||||
// set currently available.
|
// set currently available. ConsiderPreviousFrames, same id format as
|
||||||
|
// Resources, turns on stabilizeTemporalFrames (below) for just the
|
||||||
|
// resources listed, not the whole definition. An id here that isn't also in
|
||||||
|
// Resources is simply never looked up, not an error.
|
||||||
type upscaleDef struct {
|
type upscaleDef struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Scale int `json:"scale"`
|
Scale int `json:"scale"`
|
||||||
Resources []string `json:"resources"`
|
Resources []string `json:"resources"`
|
||||||
|
ConsiderPreviousFrames []string `json:"considerPreviousFrames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceLangKey identifies one resource (ignoring frame/block index) -
|
||||||
|
// what ConsiderPreviousFrames' entries are matched against, parsed through
|
||||||
|
// the same parseMdlResource as Resources itself.
|
||||||
|
type resourceLangKey struct {
|
||||||
|
id resource.ID
|
||||||
|
lang resource.Language
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMdlResource splits an animgallery-generated resource string
|
// parseMdlResource splits an animgallery-generated resource string
|
||||||
@@ -86,6 +100,7 @@ type resourceFrames struct {
|
|||||||
lang resource.Language
|
lang resource.Language
|
||||||
contentType resource.ContentType
|
contentType resource.ContentType
|
||||||
frames []*bitmap.Bitmap
|
frames []*bitmap.Bitmap
|
||||||
|
considerPreviousFrames bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
||||||
@@ -104,6 +119,15 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
def.Scale = 2
|
def.Scale = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
considerSet := make(map[resourceLangKey]bool, len(def.ConsiderPreviousFrames))
|
||||||
|
for _, label := range def.ConsiderPreviousFrames {
|
||||||
|
id, lang, err := parseMdlResource(label)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("considerPreviousFrames: %w", err)
|
||||||
|
}
|
||||||
|
considerSet[resourceLangKey{id, lang}] = true
|
||||||
|
}
|
||||||
|
|
||||||
all := make([]resourceFrames, 0, len(def.Resources))
|
all := make([]resourceFrames, 0, len(def.Resources))
|
||||||
for i, label := range def.Resources {
|
for i, label := range def.Resources {
|
||||||
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
||||||
@@ -143,7 +167,10 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
if len(frames) == 0 {
|
if len(frames) == 0 {
|
||||||
return fmt.Errorf("%s: no frames decoded", label)
|
return fmt.Errorf("%s: no frames decoded", label)
|
||||||
}
|
}
|
||||||
all = append(all, resourceFrames{label: label, id: id, lang: lang, contentType: contentType, frames: frames})
|
all = append(all, resourceFrames{
|
||||||
|
label: label, id: id, lang: lang, contentType: contentType, frames: frames,
|
||||||
|
considerPreviousFrames: considerSet[resourceLangKey{id, lang}],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
||||||
@@ -170,31 +197,25 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeViewFrames reads every block of a compound bitmap resource as a
|
// decodeViewFrames reads every block of a compound bitmap resource as a
|
||||||
// sequence of frames, each one RLE-diffed against the previous - mirrors
|
// sequence of frames - mirrors animgallery's decodeFrames, including always
|
||||||
// animgallery's decodeFrames. Each block declares its own width/height, so
|
// handing DecodeReferenced a fresh zero buffer rather than the previous
|
||||||
// the reference buffer handed to DecodeReferenced must match that block's
|
// block's real pixels (see that function's own comment for why: the game's
|
||||||
// own size, not blindly reuse the previous frame's.
|
// actual RSD8 decoder never carries a reference frame's pixels into a skip
|
||||||
|
// run, and neither does hacked's own multi-block Bitmap preview).
|
||||||
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
count := view.BlockCount()
|
count := view.BlockCount()
|
||||||
frames := make([]*bitmap.Bitmap, 0, count)
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
var prevPixels []byte
|
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
reader, err := view.Block(i)
|
reader, err := view.Block(i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -202,7 +223,8 @@ func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
|||||||
|
|
||||||
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
||||||
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
||||||
// animgallery's renderAnimationDescriptor.
|
// animgallery's renderAnimationDescriptor, including the same always-zero
|
||||||
|
// reference buffer (see decodeViewFrames' comment).
|
||||||
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
||||||
lastFrame := 0
|
lastFrame := 0
|
||||||
for _, entry := range anim.Entries {
|
for _, entry := range anim.Entries {
|
||||||
@@ -211,7 +233,6 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var frames []*bitmap.Bitmap
|
var frames []*bitmap.Bitmap
|
||||||
var prevPixels []byte
|
|
||||||
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
||||||
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -222,17 +243,11 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
|
|||||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -435,6 +450,142 @@ func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// temporalStabilizationMagnitude is the per-pixel RGB delta (0-255) above
|
||||||
|
// which stabilizeTemporalFrames treats a frame's excess deviation from the
|
||||||
|
// rest of its animation as worth correcting - see that function's own
|
||||||
|
// comment for what "excess" means here.
|
||||||
|
const temporalStabilizationMagnitude = 40.0
|
||||||
|
|
||||||
|
// canvasSize returns the pixel dimensions, at the given upscale factor,
|
||||||
|
// every frame of a resource should be treated as occupying for temporal
|
||||||
|
// comparison: the largest individual frame's own native size. Frames can be
|
||||||
|
// individually smaller than that (a storage-level crop against unchanged
|
||||||
|
// pixels, not a smaller sprite - see animgallery's writeGIF, which hit the
|
||||||
|
// same issue), so padToCanvas places each one at its top-left corner of
|
||||||
|
// this shared canvas before stabilizeTemporalFrames compares them pixel for
|
||||||
|
// pixel.
|
||||||
|
func canvasSize(frames []*bitmap.Bitmap, scale int) (int, int) {
|
||||||
|
w, h := 0, 0
|
||||||
|
for _, f := range frames {
|
||||||
|
if int(f.Header.Width) > w {
|
||||||
|
w = int(f.Header.Width)
|
||||||
|
}
|
||||||
|
if int(f.Header.Height) > h {
|
||||||
|
h = int(f.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w * scale, h * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
// padToCanvas returns img unchanged if it is already canvas-sized, or a new
|
||||||
|
// canvasW x canvasH image with img placed at its top-left corner (and
|
||||||
|
// everything else transparent) otherwise.
|
||||||
|
func padToCanvas(img *image.RGBA, canvasW, canvasH int) *image.RGBA {
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() == canvasW && b.Dy() == canvasH {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
|
||||||
|
draw.Draw(canvas, b, img, b.Min, draw.Src)
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeNearest resizes src to w x h with nearest-neighbor sampling, so a
|
||||||
|
// real source pixel is compared against itself in stabilizeTemporalFrames
|
||||||
|
// rather than a resize filter's blend of its neighbors.
|
||||||
|
func resizeNearest(src *image.RGBA, w, h int) *image.RGBA {
|
||||||
|
sb := src.Bounds()
|
||||||
|
sw, sh := sb.Dx(), sb.Dy()
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
sy := sb.Min.Y + y*sh/h
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
sx := sb.Min.X + x*sw/w
|
||||||
|
dst.Set(x, y, src.At(sx, sy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// stabilizeTemporalFrames mitigates per-frame upscale hallucination:
|
||||||
|
// upscale_batch.py runs every frame through the ONNX model independently,
|
||||||
|
// with no shared state between frames - a well-known limitation of
|
||||||
|
// single-image super-resolution. On a low-detail/ambiguous source region
|
||||||
|
// the model can guess differently frame to frame, so a detail (an extra
|
||||||
|
// claw, a stray highlight) pops in for a few frames and back out, even
|
||||||
|
// though the real low-resolution source barely changed there. This compares
|
||||||
|
// each frame's upscaled RGB value against the per-pixel median across every
|
||||||
|
// frame of the same (cyclic) animation - does the upscaled difference from
|
||||||
|
// that consensus exceed what the real low-res source's own difference from
|
||||||
|
// its consensus justifies - and corrects it where it does.
|
||||||
|
//
|
||||||
|
// The comparison is against the median across all frames, not just a
|
||||||
|
// frame's immediate predecessor: comparing only against the predecessor can
|
||||||
|
// pull a consistent frame toward an actual outlier whenever the outlier
|
||||||
|
// happens to come first in the sequence. The median is robust to that
|
||||||
|
// regardless of where the outlier frame falls in the loop.
|
||||||
|
//
|
||||||
|
// Needs at least 3 frames for a meaningful consensus (two frames have no
|
||||||
|
// independent third reference to vote with), so fewer than that are left
|
||||||
|
// untouched. Alpha is never touched, and a frame's own near-transparent
|
||||||
|
// pixel is never corrected (it re-quantizes to the background index
|
||||||
|
// regardless of its RGB value - see imageToBitmap's own 0x80 threshold).
|
||||||
|
func stabilizeTemporalFrames(upscaled, sourceResized []*image.RGBA) {
|
||||||
|
n := len(upscaled)
|
||||||
|
if n < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, h := upscaled[0].Bounds().Dx(), upscaled[0].Bounds().Dy()
|
||||||
|
|
||||||
|
median := func(vals []float64) float64 {
|
||||||
|
sorted := append([]float64(nil), vals...)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
mid := len(sorted) / 2
|
||||||
|
if len(sorted)%2 == 0 {
|
||||||
|
return (sorted[mid-1] + sorted[mid]) / 2
|
||||||
|
}
|
||||||
|
return sorted[mid]
|
||||||
|
}
|
||||||
|
|
||||||
|
outR, outG, outB := make([]float64, n), make([]float64, n), make([]float64, n)
|
||||||
|
srcR, srcG, srcB := make([]float64, n), make([]float64, n), make([]float64, n)
|
||||||
|
corrected := make([][]byte, n)
|
||||||
|
for i := range upscaled {
|
||||||
|
corrected[i] = append([]byte(nil), upscaled[i].Pix...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
ur, ug, ub, _ := upscaled[i].At(x, y).RGBA()
|
||||||
|
outR[i], outG[i], outB[i] = float64(ur>>8), float64(ug>>8), float64(ub>>8)
|
||||||
|
sr, sg, sb, _ := sourceResized[i].At(x, y).RGBA()
|
||||||
|
srcR[i], srcG[i], srcB[i] = float64(sr>>8), float64(sg>>8), float64(sb>>8)
|
||||||
|
}
|
||||||
|
medR, medG, medB := median(outR), median(outG), median(outB)
|
||||||
|
srcMedR, srcMedG, srcMedB := median(srcR), median(srcG), median(srcB)
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
_, _, _, ua := upscaled[i].At(x, y).RGBA()
|
||||||
|
if ua>>8 < 0x80 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outDev := (math.Abs(outR[i]-medR) + math.Abs(outG[i]-medG) + math.Abs(outB[i]-medB)) / 3
|
||||||
|
srcDev := (math.Abs(srcR[i]-srcMedR) + math.Abs(srcG[i]-srcMedG) + math.Abs(srcB[i]-srcMedB)) / 3
|
||||||
|
if excess := outDev - srcDev; excess > temporalStabilizationMagnitude {
|
||||||
|
off := upscaled[i].PixOffset(x, y)
|
||||||
|
corrected[i][off+0] = clamp255(medR)
|
||||||
|
corrected[i][off+1] = clamp255(medG)
|
||||||
|
corrected[i][off+2] = clamp255(medB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range upscaled {
|
||||||
|
copy(upscaled[i].Pix, corrected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// upscaleBatch runs every frame from every given resource through exactly
|
// upscaleBatch runs every frame from every given resource through exactly
|
||||||
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
||||||
// mode) and returns each resource's upscaled frames in the same order as
|
// mode) and returns each resource's upscaled frames in the same order as
|
||||||
@@ -495,8 +646,8 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
|
|||||||
|
|
||||||
result := make([][]*bitmap.Bitmap, len(all))
|
result := make([][]*bitmap.Bitmap, len(all))
|
||||||
for ri, rf := range all {
|
for ri, rf := range all {
|
||||||
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
rgbaFrames := make([]*image.RGBA, len(rf.frames))
|
||||||
for fi, frame := range rf.frames {
|
for fi := range rf.frames {
|
||||||
outPath := filepath.Join(outDir, frameName(ri, fi))
|
outPath := filepath.Join(outDir, frameName(ri, fi))
|
||||||
outFile, err := os.Open(outPath)
|
outFile, err := os.Open(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -510,9 +661,29 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, closeErr
|
return nil, closeErr
|
||||||
}
|
}
|
||||||
rgba := toRGBA(upscaledImg)
|
rgbaFrames[fi] = toRGBA(upscaledImg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rf.considerPreviousFrames {
|
||||||
|
canvasW, canvasH := canvasSize(rf.frames, scale)
|
||||||
|
canvasFrames := make([]*image.RGBA, len(rgbaFrames))
|
||||||
|
sourceResized := make([]*image.RGBA, len(rf.frames))
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
|
canvasFrames[fi] = padToCanvas(rgbaFrames[fi], canvasW, canvasH)
|
||||||
|
resized := resizeNearest(bitmapToImage(frame, pal), int(frame.Header.Width)*scale, int(frame.Header.Height)*scale)
|
||||||
|
sourceResized[fi] = padToCanvas(resized, canvasW, canvasH)
|
||||||
|
}
|
||||||
|
stabilizeTemporalFrames(canvasFrames, sourceResized)
|
||||||
|
for fi := range rgbaFrames {
|
||||||
|
b := rgbaFrames[fi].Bounds()
|
||||||
|
draw.Draw(rgbaFrames[fi], b, canvasFrames[fi], b.Min, draw.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
||||||
upscaled[fi] = imageToBitmap(rgba, pal, transparent, frame.Header.Area)
|
upscaled[fi] = imageToBitmap(rgbaFrames[fi], pal, transparent, frame.Header.Area)
|
||||||
}
|
}
|
||||||
result[ri] = upscaled
|
result[ri] = upscaled
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user