Fix animgallery rendering bugs and mitigate AI-upscale frame flicker
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:
ml
2026-10-27 07:54:19 +01:00
parent a8d7ff76df
commit 90303894ce
7 changed files with 538 additions and 123 deletions
+289 -77
View File
@@ -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) &middot; %s</label><br>%dx%d, %d frame(s), %s &middot; %s &middot; %s</div>",