Files
sow-tools/internal/assets/convert.go
T
archvillainetteandClaude Opus 4.8 dd01c96473 feat(assets): seven asset commands + Run dispatch/helpers
check-dupes, clean-dupes, check-mdl, fix-mdl, convert, upscale, compile
behind a single runner indirection, mirroring internal/depot's Run shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:13:54 +02:00

117 lines
3.5 KiB
Go

package assets
import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
var textureExts = map[string]bool{".dds": true, ".png": true, ".tga": true}
// runConvert converts textures in place, flipping vertically exactly once per
// conversion. --to defaults to dds. Files already in the target format are
// skipped.
func runConvert(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("convert", flag.ContinueOnError)
fs.SetOutput(stderr)
to := fs.String("to", "dds", "target format: dds|png|tga")
backend := fs.String("backend", "magick", "ImageMagick-compatible binary")
nonRecursive := fs.Bool("non-recursive", false, "do not descend into subdirectories")
if err := fs.Parse(args); err != nil {
return exitUsage
}
target := strings.ToLower(*to)
if target != "dds" && target != "png" && target != "tga" {
fmt.Fprintln(stderr, "assets convert: --to must be dds, png, or tga")
return exitUsage
}
dirs := fs.Args()
if len(dirs) == 0 {
fmt.Fprintln(stderr, "assets convert: usage: convert [--to dds|png|tga] <dir>...")
return exitUsage
}
magick := look(*backend)
if magick == "" {
fmt.Fprintf(stderr, "assets convert: backend %q not found — install ImageMagick (magick)\n", *backend)
return exitTool
}
files, err := walk(dirs, textureExts, !*nonRecursive)
if err != nil {
fmt.Fprintln(stderr, "assets convert:", err)
return exitUsage
}
targetExt := "." + target
failed := false
for _, src := range files {
srcExt := strings.ToLower(filepath.Ext(src))
if srcExt == targetExt {
continue // already in the target format
}
dst := strings.TrimSuffix(src, filepath.Ext(src)) + targetExt
var convErr error
if target == "dds" {
convErr = pngToDDS(magick, src, dst)
} else {
convErr = ddsToPNG(magick, src, dst) // works for any decodable source
}
if convErr != nil {
fmt.Fprintf(stderr, "assets convert: %s: %v\n", src, convErr)
failed = true
continue
}
if dst != src {
if err := os.Remove(src); err != nil {
fmt.Fprintf(stderr, "assets convert: %s: %v\n", src, err)
failed = true
}
}
}
if failed {
return exitFail
}
return exitOK
}
// ddsToPNG decodes src to dst (PNG/TGA by dst's extension), flipping vertically
// once. The single flip is the defining NWN behavior.
func ddsToPNG(magickBin, src, dst string) error {
if out, err := runner("", nil, magickBin, src, "-flip", dst); err != nil {
return fmt.Errorf("magick: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// pngToDDS encodes src to a DDS at dst, flipping vertically once and choosing
// DXT1 for opaque images, DXT5 for images with alpha.
func pngToDDS(magickBin, src, dst string) error {
compression := "dxt1"
if hasAlpha(magickBin, src) {
compression = "dxt5"
}
// ponytail: mipmaps rely on magick's default full chain (NWN wants a
// pyramid, which magick writes by default). Add `-define dds:mipmaps=N`
// here if a specific count is ever required.
out, err := runner("", nil, magickBin, src, "-flip",
"-define", "dds:compression="+compression, dst)
if err != nil {
return fmt.Errorf("magick: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// hasAlpha reports whether src has any non-opaque pixel, via `magick identify`.
// On any probe error it assumes alpha (DXT5), the safe/lossless-alpha default.
func hasAlpha(magickBin, src string) bool {
out, err := runner("", nil, magickBin, "identify", "-format", "%[opaque]", src)
if err != nil {
return true
}
return !strings.EqualFold(strings.TrimSpace(string(out)), "true")
}