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:
+289
-77
@@ -1,9 +1,13 @@
|
||||
// Command animgallery scans a game data directory for every multi-frame
|
||||
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||
// 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
|
||||
// by resource ID.
|
||||
// Shock uses both conventions) and renders each one as an animated GIF, plus
|
||||
// a static filmstrip of every frame side by side (the loop alone doesn't let
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -12,7 +16,9 @@ import (
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -27,15 +33,22 @@ import (
|
||||
|
||||
// galleryEntry describes one rendered animation for the HTML index.
|
||||
type galleryEntry struct {
|
||||
id resource.ID
|
||||
lang resource.Language
|
||||
sourceFile string
|
||||
kind string
|
||||
frameCount int
|
||||
width int
|
||||
height int
|
||||
transparent bool
|
||||
gifName string
|
||||
id resource.ID
|
||||
lang resource.Language
|
||||
sourceFile string
|
||||
kind string
|
||||
frameCount int
|
||||
width int
|
||||
height int
|
||||
transparent bool
|
||||
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
|
||||
@@ -167,6 +180,32 @@ func main() {
|
||||
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))
|
||||
for key := range sourceFileByKey {
|
||||
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||
@@ -187,28 +226,30 @@ func main() {
|
||||
// Re-resolved through the layered mod, not the raw per-directory
|
||||
// viewer, so an overriding mod's version wins here exactly like it
|
||||
// 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 {
|
||||
log.Printf("skipping %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case view.ContentType() == resource.Animation:
|
||||
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||
// A later layer wins this key over the first (base) -data directory
|
||||
// - also render the base directory's own version of it, so the two
|
||||
// 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 {
|
||||
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
log.Printf("skipping base comparison for %s (%s): %v", key.ID, sourceFile, err)
|
||||
} 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 {
|
||||
@@ -228,30 +269,31 @@ func main() {
|
||||
}
|
||||
|
||||
// decodeFrames reads every block of a compound bitmap resource as a
|
||||
// sequence of frames, each one RLE-diffed against the previous - the same
|
||||
// chaining hacked's own editor/animations/View.go uses when importing a
|
||||
// GIF, done here in reverse (decode instead of encode).
|
||||
// sequence of frames. Each block's RLE data can carry a "skip" run, which
|
||||
// bitmap.DecodeReferenced leaves untouched in whatever buffer it's handed -
|
||||
// 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) {
|
||||
count := view.BlockCount()
|
||||
frames := make([]*bitmap.Bitmap, 0, count)
|
||||
var prevPixels []byte
|
||||
for i := 0; i < count; i++ {
|
||||
reader, err := view.Block(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
return make([]byte, int(w)*int(h)), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
}
|
||||
return frames, nil
|
||||
@@ -265,34 +307,55 @@ func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
maxWidth, maxHeight := 0, 0
|
||||
canvasWidth, canvasHeight := minWidth, minHeight
|
||||
for _, bmp := range frames {
|
||||
if int(bmp.Header.Width) > maxWidth {
|
||||
maxWidth = int(bmp.Header.Width)
|
||||
if int(bmp.Header.Width) > canvasWidth {
|
||||
canvasWidth = int(bmp.Header.Width)
|
||||
}
|
||||
if int(bmp.Header.Height) > maxHeight {
|
||||
maxHeight = int(bmp.Header.Height)
|
||||
if int(bmp.Header.Height) > canvasHeight {
|
||||
canvasHeight = int(bmp.Header.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// zero value) means play once, which is why these looped only once
|
||||
// before this fix.
|
||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
||||
// zero value) means play once.
|
||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: canvasWidth, Height: canvasHeight}}
|
||||
canvasRect := image.Rect(0, 0, canvasWidth, canvasHeight)
|
||||
for i, bmp := range frames {
|
||||
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)
|
||||
img := image.NewPaletted(canvasRect, colorPal)
|
||||
frameWidth, frameHeight := int(bmp.Header.Width), int(bmp.Header.Height)
|
||||
if len(bmp.Pixels) == frameWidth*frameHeight {
|
||||
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.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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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() {
|
||||
if r := recover(); r != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
@@ -323,33 +480,43 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
|
||||
}
|
||||
delays := make([]int, len(frames))
|
||||
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())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||
// 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{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
|
||||
frameCount: len(frames),
|
||||
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
filmstripName: filmstripName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||
// 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() {
|
||||
if r := recover(); r != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
@@ -375,7 +542,6 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
||||
|
||||
var frames []*bitmap.Bitmap
|
||||
var delays []int
|
||||
var prevPixels []byte
|
||||
frameIndex := 0
|
||||
for _, entry := range anim.Entries {
|
||||
for frameIndex <= int(entry.LastFrame) {
|
||||
@@ -388,22 +554,21 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
||||
if err != nil {
|
||||
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) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
return make([]byte, int(w)*int(h)), nil
|
||||
})
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
delay := int(entry.FrameTime) / 10
|
||||
delay := int(entry.FrameTime) / 10 * gallerySpeedFactor
|
||||
if delay <= 0 {
|
||||
delay = 10
|
||||
delay = 10 * gallerySpeedFactor
|
||||
}
|
||||
delays = append(delays, delay)
|
||||
frameIndex++
|
||||
@@ -413,19 +578,48 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
||||
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||
}
|
||||
|
||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||
// 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{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
|
||||
frameCount: len(frames),
|
||||
width: int(anim.Width), height: int(anim.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
filmstripName: filmstripName,
|
||||
}, 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
|
||||
// name mdlp will need (its own decode path differs: an "Animation"
|
||||
// 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(".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.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 label{cursor:pointer}")
|
||||
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.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||
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 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\"> ",
|
||||
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>",
|
||||
|
||||
Reference in New Issue
Block a user