/* —————————————————————————————————————————————————————————————————————————— Proyecto Imaginator ————————————————————————————————————————————————————————————————————————— Fichero main.go Package Imaginator Autor Juan V. Navarro juanvnl@activething.com Creado 26/01/2026 Mejorado 20/03/2026 ————————————————————————————————————————————————————————————————————————— LICENSES AND TERMS OF USE ------------------------- This software is licensed under the Elastic License v2.0 (the "License"). For full terms and additional information regarding permitted and prohibited uses, please visit: https://activething.com/ATGO/licenses You may use, copy, modify, and redistribute this software internally within your organization for any purpose, including research, development, and testing, subject to the terms of this License. You may NOT, however, use, provide, distribute, or make this software available to any third party as part of a hosted service, SaaS offering, or commercial product without first obtaining a commercial license from Active Thing. ————————————————————————————————————————————————————————————————————————— Web : activething.com | activething.com/Imaginator git : g.activething.com | github.com/activething/Imaginator Correo : dev@activething.com ————————————————————————————————————————————————————————————————————————— "No deseo caminar sobre el agua", dijo Siddhartha. Que los antiguos chamanes se contenten con tales habilidades. —— Hermann Hesse, Siddhartha ————————————————————————————————————————————————————————————————————————— Copyright (c) 2026 Active Thing ————————————————————————————————————————————————————————————————————————— */ package main import ( "bytes" "encoding/base64" "flag" "fmt" "image" "image/jpeg" "image/png" "log" "os" "path/filepath" "regexp" "strings" "sync" "sync/atomic" "time" "unicode" "github.com/disintegration/imaging" _ "golang.org/x/image/bmp" "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) const version = "2.6.0" // ───────────────────────────────────────────────────────────────────────────── // Tipos // ───────────────────────────────────────────────────────────────────────────── type Config struct { InputDir string OutputDir string Width int Height int Quality int Format string Filter string Workers int Verbose bool KeepStructure bool Prefix string Suffix string DryRun bool Replace string // pares orig/dest separados por coma, case sensitive ReplaceI string // ídem, case insensitive ReplaceP string // ídem, case insensitive + preserve case FileFilter string // glob sobre el nombre del fichero Lowercase bool // normalize output filename to lowercase Uppercase bool // normalize output filename to uppercase B64Ext string // if set, encode output as base64 with this extension ("" = disabled, "b64" = default) B64Enabled bool // true when -b64 flag was provided TSFile string // if set, write a TypeScript file with all images as base64 data URIs NoSpaces string // replacement char for spaces in output filename ("" = disabled, "_" = default) NoSpacesOn bool // true when -nospaces flag was provided } // ReplacePair representa un par de sustitución ya parseado type ReplacePair struct { From string To string } type ProcessResult struct { Filename string OutputPath string Success bool Error error Duration time.Duration InputSize int64 OutputSize int64 B64Data string // populated when B64Enabled or TSFile is set: "data:image/png;base64,..." ImageKey string // stem used as key in the TS file } type ImageSummary struct { Files []string ByExt map[string]int ReadOnlyExts []string // read-only formats present in the batch } // ───────────────────────────────────────────────────────────────────────────── // Entrada // ───────────────────────────────────────────────────────────────────────────── func main() { cfg := parseFlags() if err := validateConfig(cfg); err != nil { log.Fatal(err) } if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { log.Fatalf("error creating output directory: %v", err) } summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter) if err != nil { log.Fatalf("error reading source directory: %v", err) } if len(summary.Files) == 0 { fmt.Println(" No images found to process.") return } // Type breakdown fmt.Printf(" Found %d images:\n", len(summary.Files)) for _, ext := range []string{".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"} { if n := summary.ByExt[ext]; n > 0 { line := fmt.Sprintf(" %-8s %d", ext, n) if (ext == ".webp" || ext == ".bmp") && cfg.Format == "" { line += " ⚠ read-only" } fmt.Println(line) } } if len(summary.ReadOnlyExts) > 0 && cfg.Format == "" { joined := strings.Join(summary.ReadOnlyExts, ", ") fmt.Println() fmt.Printf(" WARNING: batch contains read-only formats (%s).\n", joined) fmt.Println(" Output files in that format may be corrupted.") fmt.Println(" Add -format jpg or -format png to convert them.") } fmt.Println() if cfg.DryRun { fmt.Println(" [dry-run] Output paths:") fmt.Println() for _, f := range summary.Files { out := buildOutputPath(f, cfg) fmt.Printf(" %s → %s\n", f, out) } fmt.Println() return } fmt.Printf(" Processing with %d workers...\n\n", cfg.Workers) processImages(summary.Files, cfg) } // ───────────────────────────────────────────────────────────────────────────── // Flags y validación // ───────────────────────────────────────────────────────────────────────────── func parseFlags() *Config { cfg := &Config{} flag.StringVar(&cfg.InputDir, "dir", "", "Source directory") flag.StringVar(&cfg.OutputDir, "out", "", "Output directory") flag.IntVar(&cfg.Width, "width", 0, "Target width") flag.IntVar(&cfg.Height, "height", 0, "Target height") flag.IntVar(&cfg.Quality, "quality", 85, "JPEG quality (1-100)") flag.StringVar(&cfg.Format, "format", "", "Output format: jpg, png, tif") flag.StringVar(&cfg.Filter, "resample", "lanczos", "Resampling filter: lanczos, cubic, linear, box") flag.IntVar(&cfg.Workers, "workers", 4, "Concurrent workers") flag.BoolVar(&cfg.Verbose, "verbose", false, "Verbose output") flag.BoolVar(&cfg.KeepStructure, "keepstructure", false, "Preserve subdirectory structure") flag.StringVar(&cfg.Prefix, "prefix", "", "Prefix for output filename") flag.StringVar(&cfg.Suffix, "suffix", "", "Suffix for output filename (before extension)") flag.BoolVar(&cfg.DryRun, "dry-run", false, "Show what would be processed without executing") flag.StringVar(&cfg.Replace, "replace", "", "Replace tokens in filename: orig/dest[,orig2/dest2] (case sensitive)") flag.StringVar(&cfg.ReplaceI, "replacei", "", "Replace tokens in filename: orig/dest[,...] (case insensitive)") flag.StringVar(&cfg.ReplaceP, "replacep", "", "Replace tokens in filename: orig/dest[,...] (case insensitive + preserve case)") flag.StringVar(&cfg.FileFilter, "filter", "", "Glob pattern to filter filenames, e.g: photo_*.jpg") flag.BoolVar(&cfg.Lowercase, "lowercase", false, "Normalize output filename to lowercase") flag.BoolVar(&cfg.Uppercase, "uppercase", false, "Normalize output filename to uppercase") flag.StringVar(&cfg.B64Ext, "b64", "", "Encode output as base64; optional value sets the file extension (default: b64)") flag.StringVar(&cfg.TSFile, "ts", "", "Write a TypeScript file with all images as base64 data URIs") flag.StringVar(&cfg.NoSpaces, "nospaces", "", "Replace spaces in output filename; optional value sets replacement char (default: _)") configurarAyuda() flag.Parse() // -b64 without a value: detect if the flag was provided but left empty // and default the extension to "b64" flag.Visit(func(f *flag.Flag) { if f.Name == "b64" { cfg.B64Enabled = true if cfg.B64Ext == "" { cfg.B64Ext = "b64" } } if f.Name == "nospaces" { cfg.NoSpacesOn = true if cfg.NoSpaces == "" { cfg.NoSpaces = "_" } } }) return cfg } func validateConfig(cfg *Config) error { if cfg.InputDir == "" || cfg.OutputDir == "" { flag.Usage() return fmt.Errorf("missing required flags: -dir and -out are required") } if cfg.Width == 0 && cfg.Height == 0 { return fmt.Errorf("at least one of -width or -height must be specified") } if cfg.Width < 0 || cfg.Height < 0 { return fmt.Errorf("dimensions cannot be negative") } if cfg.Quality < 1 || cfg.Quality > 100 { return fmt.Errorf("quality must be between 1 and 100") } if cfg.Format != "" && cfg.Format != "jpg" && cfg.Format != "jpeg" && cfg.Format != "png" && cfg.Format != "tif" && cfg.Format != "tiff" { return fmt.Errorf("unsupported output format: %s (options: jpg, png, tif)", cfg.Format) } absInput, err := filepath.Abs(cfg.InputDir) if err != nil { return fmt.Errorf("error resolving source path: %v", err) } absOutput, err := filepath.Abs(cfg.OutputDir) if err != nil { return fmt.Errorf("error resolving output path: %v", err) } if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" && cfg.TSFile == "" && !cfg.B64Enabled { return fmt.Errorf( "input and output directories cannot be the same\n" + " hint: use -prefix, -suffix, -b64 or -ts to process in place without overwriting originals") } if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) { return fmt.Errorf("source directory does not exist: %s", cfg.InputDir) } if cfg.Workers < 1 { cfg.Workers = 1 } // Validate replacement pairs for _, raw := range []string{cfg.Replace, cfg.ReplaceI, cfg.ReplaceP} { if raw == "" { continue } for _, pair := range strings.Split(raw, ",") { parts := strings.SplitN(pair, "/", 2) if len(parts) != 2 || parts[0] == "" { return fmt.Errorf("invalid replacement pair %q — expected format: old/new", pair) } } } // Validate filter glob if cfg.FileFilter != "" { if _, err := filepath.Match(cfg.FileFilter, ""); err != nil { return fmt.Errorf("invalid -filter pattern %q: %v", cfg.FileFilter, err) } } if cfg.Lowercase && cfg.Uppercase { return fmt.Errorf("-lowercase and -uppercase are mutually exclusive") } if cfg.B64Enabled && cfg.TSFile != "" { return fmt.Errorf("-b64 and -ts are mutually exclusive") } return nil } // ───────────────────────────────────────────────────────────────────────────── // Recolección de ficheros // ───────────────────────────────────────────────────────────────────────────── func collectImageFiles(root string, glob string) (*ImageSummary, error) { validExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".tif": true, ".tiff": true, ".bmp": true, } readOnlyExts := map[string]bool{ ".webp": true, ".bmp": true, } summary := &ImageSummary{ ByExt: make(map[string]int), } seen := map[string]bool{} err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(path)) if !validExts[ext] { return nil } // Aplicar glob sobre el nombre del fichero (sin ruta) if glob != "" { matched, merr := filepath.Match(glob, filepath.Base(path)) if merr != nil || !matched { return nil } } summary.Files = append(summary.Files, path) summary.ByExt[ext]++ if readOnlyExts[ext] && !seen[ext] { summary.ReadOnlyExts = append(summary.ReadOnlyExts, ext) seen[ext] = true } return nil }) return summary, err } // ───────────────────────────────────────────────────────────────────────────── // Ruta de salida // ───────────────────────────────────────────────────────────────────────────── func buildOutputPath(inputPath string, cfg *Config) string { relPath, err := filepath.Rel(cfg.InputDir, inputPath) if err != nil { relPath = filepath.Base(inputPath) } base := filepath.Base(relPath) dir := filepath.Dir(relPath) ext := filepath.Ext(base) stem := base[:len(base)-len(ext)] // Formato de salida outExt := ext if cfg.Format != "" { outExt = "." + cfg.Format switch cfg.Format { case "jpeg": outExt = ".jpg" case "tiff": outExt = ".tif" } } stem = applyStemReplacements(stem, cfg) // Replace spaces if cfg.NoSpacesOn { stem = strings.ReplaceAll(stem, " ", cfg.NoSpaces) } // Case normalisation — applied after all other stem transformations, // never on the extension (avoids .JPG or .TIF on case-sensitive filesystems) switch { case cfg.Lowercase: stem = strings.ToLower(stem) case cfg.Uppercase: stem = strings.ToUpper(stem) } // Base64 extension override — applied last, replaces the image extension if cfg.B64Enabled { outExt = "." + cfg.B64Ext } newName := cfg.Prefix + stem + cfg.Suffix + outExt if cfg.KeepStructure { return filepath.Join(cfg.OutputDir, dir, newName) } return filepath.Join(cfg.OutputDir, newName) } // ───────────────────────────────────────────────────────────────────────────── // Orquestación de workers // ───────────────────────────────────────────────────────────────────────────── func processImages(files []string, cfg *Config) { var ( wg sync.WaitGroup mu sync.Mutex processed atomic.Int32 errCount atomic.Int32 totalIn atomic.Int64 totalOut atomic.Int64 total = int32(len(files)) start = time.Now() tsEntries []ProcessResult // collected in order for -ts ) jobs := make(chan string, len(files)) resultsCh := make(chan ProcessResult, len(files)) for i := 0; i < cfg.Workers; i++ { wg.Add(1) go func() { defer wg.Done() for inputPath := range jobs { result := processImage(inputPath, cfg) resultsCh <- result current := processed.Add(1) mu.Lock() if result.Success { totalIn.Add(result.InputSize) totalOut.Add(result.OutputSize) if cfg.Verbose { fmt.Printf(" [%d/%d] ✓ %-40s → %-40s (%s)\n", current, total, truncate(result.Filename, 40), truncate(filepath.Base(result.OutputPath), 40), result.Duration.Round(time.Millisecond)) } else { fmt.Printf(" [%d/%d] ✓ %s\n", current, total, result.Filename) } } else { errCount.Add(1) fmt.Printf(" [%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) } mu.Unlock() } }() } for _, f := range files { jobs <- f } close(jobs) // Collect results while workers run go func() { wg.Wait() close(resultsCh) }() for r := range resultsCh { if r.Success && cfg.TSFile != "" { tsEntries = append(tsEntries, r) } } // Write TypeScript file if cfg.TSFile != "" && len(tsEntries) > 0 { if err := writeTSFile(cfg.TSFile, tsEntries); err != nil { fmt.Printf("\n ✗ error writing TypeScript file: %v\n", err) } else { fmt.Printf("\n ✓ TypeScript file written: %s (%d images)\n", cfg.TSFile, len(tsEntries)) } } elapsed := time.Since(start).Round(time.Millisecond) ok := total - errCount.Load() inMB := float64(totalIn.Load()) / 1024 / 1024 outMB := float64(totalOut.Load()) / 1024 / 1024 var saving string if totalIn.Load() > 0 { pct := 100.0 - (float64(totalOut.Load())/float64(totalIn.Load()))*100.0 saving = fmt.Sprintf("%.1f%%", pct) } else { saving = "n/a" } fmt.Printf("\n") fmt.Printf(" ══════════════════════════════════════════════════════\n") fmt.Printf(" Done in %s\n\n", elapsed) fmt.Printf(" Images %d total / %d ok / %d errors\n", total, ok, errCount.Load()) fmt.Printf(" Size %.2f MB in → %.2f MB out (%s saved)\n", inMB, outMB, saving) fmt.Printf(" ══════════════════════════════════════════════════════\n\n") } // ───────────────────────────────────────────────────────────────────────────── // Procesado individual // ───────────────────────────────────────────────────────────────────────────── func processImage(inputPath string, cfg *Config) ProcessResult { start := time.Now() result := ProcessResult{ Filename: filepath.Base(inputPath), } if fi, err := os.Stat(inputPath); err == nil { result.InputSize = fi.Size() } outputPath := buildOutputPath(inputPath, cfg) result.OutputPath = outputPath // Stem for TS key (after all naming transformations, without extension) base := filepath.Base(outputPath) result.ImageKey = base[:len(base)-len(filepath.Ext(base))] if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { result.Error = fmt.Errorf("error creating directory: %v", err) return result } f, err := os.Open(inputPath) if err != nil { result.Error = fmt.Errorf("error opening file: %v", err) return result } defer f.Close() img, srcFormat, err := image.Decode(f) if err != nil { result.Error = fmt.Errorf("error decoding image: %v", err) return result } if cfg.Verbose { bounds := img.Bounds() log.Printf(" %s: format=%s size=%dx%d", result.Filename, srcFormat, bounds.Dx(), bounds.Dy()) } resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter)) // Encode image into an in-memory buffer first var buf bytes.Buffer outputFormat := cfg.Format if outputFormat == "" { // When b64 or ts mode, we need a real image format — default to the source format srcExt := strings.ToLower(filepath.Ext(inputPath)) srcMime := extToFormat(strings.TrimPrefix(srcExt, ".")) if cfg.B64Enabled || cfg.TSFile != "" { outputFormat = srcMime } else { ext := strings.ToLower(filepath.Ext(outputPath)) outputFormat = strings.TrimPrefix(ext, ".") } } mimeType, encErr := encodeImage(&buf, resized, outputFormat, cfg.Quality) if encErr != nil { result.Error = fmt.Errorf("error encoding image: %v", encErr) return result } // ── Base64 mode ────────────────────────────────────────────────────────── if cfg.B64Enabled || cfg.TSFile != "" { encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) result.B64Data = "data:" + mimeType + ";base64," + encoded } // ── Write output file ──────────────────────────────────────────────────── if cfg.TSFile == "" { // Write b64 text file or normal binary image var writeErr error if cfg.B64Enabled { writeErr = os.WriteFile(outputPath, []byte(result.B64Data), 0644) } else { writeErr = os.WriteFile(outputPath, buf.Bytes(), 0644) } if writeErr != nil { result.Error = fmt.Errorf("error writing output file: %v", writeErr) return result } } // When -ts is active, writing is deferred to processImages after all goroutines finish if fi, err := os.Stat(outputPath); err == nil { result.OutputSize = fi.Size() } result.Success = true result.Duration = time.Since(start) return result } // ───────────────────────────────────────────────────────────────────────────── // Utilidades // ───────────────────────────────────────────────────────────────────────────── func getResampleFilter(name string) imaging.ResampleFilter { switch strings.ToLower(name) { case "lanczos": return imaging.Lanczos case "cubic": return imaging.CatmullRom case "linear": return imaging.Linear case "box": return imaging.Box default: return imaging.Lanczos } } // encodeImage writes the image into buf using the given format and returns // the MIME type string to use in data URIs. func encodeImage(buf *bytes.Buffer, img image.Image, format string, quality int) (mimeType string, err error) { switch strings.ToLower(format) { case "jpg", "jpeg": err = jpeg.Encode(buf, img, &jpeg.Options{Quality: quality}) mimeType = "image/jpeg" case "png": err = png.Encode(buf, img) mimeType = "image/png" case "tif", "tiff": err = tiff.Encode(buf, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) mimeType = "image/tiff" case "webp", "bmp": err = fmt.Errorf("format %s is read-only — use -format jpg, png or tif", format) default: err = fmt.Errorf("unknown output format: %s", format) } return } // extToFormat normalises a file extension to a canonical format name. func extToFormat(ext string) string { switch ext { case "jpeg": return "jpg" case "tiff": return "tif" default: return ext } } func truncate(s string, max int) string { if len(s) <= max { return s } return "…" + s[len(s)-(max-1):] } // ───────────────────────────────────────────────────────────────────────────── // TypeScript export file // ───────────────────────────────────────────────────────────────────────────── func writeTSFile(path string, entries []ProcessResult) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } var sb strings.Builder sb.WriteString("export const images: Record = {\n") for _, e := range entries { // Sanitize key: replace any non-alphanumeric/underscore chars with _ key := sanitizeTSKey(e.ImageKey) fmt.Fprintf(&sb, " %s: %q,\n", key, e.B64Data) } sb.WriteString("}\n") return os.WriteFile(path, []byte(sb.String()), 0644) } // sanitizeTSKey ensures the key is a valid JS identifier. // Replaces sequences of non-word characters with underscores, // and prepends _ if the key starts with a digit. func sanitizeTSKey(s string) string { re := regexp.MustCompile(`[^\w]`) key := re.ReplaceAllString(s, "_") if len(key) > 0 && key[0] >= '0' && key[0] <= '9' { key = "_" + key } return key } // applyStemReplacements aplica los tres modos de sustitución al stem del fichero. // El orden de aplicación es: -replace → -replacei → -replacep. func applyStemReplacements(stem string, cfg *Config) string { if cfg.Replace != "" { for _, p := range parsePairs(cfg.Replace) { stem = strings.ReplaceAll(stem, p.From, p.To) } } if cfg.ReplaceI != "" { for _, p := range parsePairs(cfg.ReplaceI) { stem = replaceInsensitive(stem, p.From, p.To, false) } } if cfg.ReplaceP != "" { for _, p := range parsePairs(cfg.ReplaceP) { stem = replaceInsensitive(stem, p.From, p.To, true) } } return stem } // parsePairs descompone "orig1/dest1,orig2/dest2" en una slice de ReplacePair. func parsePairs(raw string) []ReplacePair { var pairs []ReplacePair for _, item := range strings.Split(raw, ",") { parts := strings.SplitN(strings.TrimSpace(item), "/", 2) if len(parts) == 2 && parts[0] != "" { pairs = append(pairs, ReplacePair{From: parts[0], To: parts[1]}) } } return pairs } // replaceInsensitive sustituye todas las ocurrencias de from en s de forma // case-insensitive. Si preserveCase es true, la sustitución adapta las // mayúsculas del token de destino para que reflejen las del token encontrado. func replaceInsensitive(s, from, to string, preserveCase bool) string { if from == "" { return s } re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(from)) return re.ReplaceAllStringFunc(s, func(match string) string { if !preserveCase { return to } return matchCase(match, to) }) } // matchCase aplica el patrón de mayúsculas de src a dst. // // "imagen" → "picture" (todo minúsculas → todo minúsculas) // "Imagen" → "Picture" (primera mayúscula → primera mayúscula) // "IMAGEN" → "PICTURE" (todo mayúsculas → todo mayúsculas) // "iMaGeN" → "picture" (mixto → minúsculas, caso seguro) func matchCase(src, dst string) string { srcRunes := []rune(src) dstRunes := []rune(dst) if len(srcRunes) == 0 || len(dstRunes) == 0 { return dst } allUpper := true allLower := true for _, r := range srcRunes { if unicode.IsLetter(r) { if !unicode.IsUpper(r) { allUpper = false } if !unicode.IsLower(r) { allLower = false } } } switch { case allUpper: return strings.ToUpper(dst) case allLower: return strings.ToLower(dst) case unicode.IsUpper(srcRunes[0]): // Primera letra mayúscula, resto minúsculas out := make([]rune, len(dstRunes)) for i, r := range dstRunes { if i == 0 { out[i] = unicode.ToUpper(r) } else { out[i] = unicode.ToLower(r) } } return string(out) default: return strings.ToLower(dst) } } func configurarAyuda() { flag.Usage = func() { fmt.Print(` ██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗ ██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗ ██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝ ██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗ ██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ IMAGINATOR VER 2.7 ────────────────────────────────────────────────────────────────────────────── activething.com/imaginator · dev@activething.com · ©2026 ActiveThing ────────────────────────────────────────────────────────────────────────────── USAGE imaginator -dir -out { -width N | -height N } [options] REQUIRED -dir string Source directory -out string Output directory At least one of: -width int Target width (keeps aspect ratio if -height is omitted) -height int Target height (keeps aspect ratio if -width is omitted) SUPPORTED FORMATS Read jpg · jpeg · png · tif · tiff · bmp · webp Write jpg · png · tif (bmp and webp are read-only — use -format jpg, png or tif to convert) IMAGE OPTIONS -quality int JPEG quality 1–100 [85] -format string Output format: jpg, png, tif [keep original] -resample string Resampling filter: lanczos · cubic · linear · box [lanczos] -b64 string Encode output as base64; optional value = file extension [b64] -b64 → saves as nombre.b64 -b64 png → saves as nombre.png (base64 content) -ts string Write a TypeScript file with all images as base64 data URIs -ts assets.ts → export const images: Record = { ... } (-b64 and -ts are mutually exclusive) NAMING OPTIONS -prefix string Prefix for output filename [""] -suffix string Suffix for output filename (before extension) [""] -lowercase Normalize output filename to lowercase [false] -uppercase Normalize output filename to uppercase [false] -nospaces string Replace spaces in filename; optional value = replacement char [_] -nospaces → "foto de viaje.jpg" → "foto_de_viaje.jpg" -nospaces - → "foto de viaje.jpg" → "foto-de-viaje.jpg" -nospaces "" → "foto de viaje.jpg" → "fotodeviaje.jpg" -replace string Replace token in filename: old/new[,old2/new2] case sensitive: photo→picture, Photo→Photo -replacei string Same as -replace but case insensitive photo→picture, Photo→picture, PHOTO→picture -replacep string Same as -replacei but preserves capitalization photo→picture, Photo→Picture, PHOTO→PICTURE FILTER OPTIONS -filter string Glob pattern on filename to select input files [""] e.g: "photo_*.jpg" "DSC_????.png" "*.webp" EXECUTION OPTIONS -workers int Concurrent workers [4] -keepstructure Preserve subdirectory structure [false] -dry-run Show what would be processed without running [false] -verbose Verbose output per image [false] EXAMPLES # Resize to 800 px wide, preserve aspect ratio imaginator -dir ./photos -out ./thumbs -width 800 # Convert everything to JPG 1920×1080 at quality 90 imaginator -dir ./raw -out ./web -width 1920 -height 1080 -format jpg -quality 90 # 8 workers, preserve folder tree, quality 85 imaginator -dir ./raw -out ./web -width 1024 -workers 8 -keepstructure -quality 85 # Process in place adding suffix "_800w" imaginator -dir ./photos -out ./photos -width 800 -suffix _800w # Process only files matching a glob imaginator -dir ./raw -out ./web -width 1920 -filter "DSC_*.jpg" # Replace tokens in output filenames (case sensitive) imaginator -dir ./raw -out ./web -width 800 -replace "photo/img,picture/pic" # Replace preserving capitalization imaginator -dir ./raw -out ./web -width 800 -replacep "photo/image" # photo_001.jpg → image_001.jpg # Photo_001.jpg → Image_001.jpg # PHOTO_001.jpg → IMAGE_001.jpg # Encode all output images as base64 files (.b64) imaginator -dir ./raw -out ./web -width 800 -b64 # Encode as base64 but keep .png extension imaginator -dir ./raw -out ./web -width 800 -format png -b64 png # Generate a TypeScript file with all images as data URIs imaginator -dir ./icons -out ./dist -width 64 -format png -ts src/assets/icons.ts # Replace spaces in output filenames with underscores imaginator -dir ./raw -out ./web -width 800 -nospaces # Replace spaces with hyphens imaginator -dir ./raw -out ./web -width 800 -nospaces - # Normalize all output filenames to lowercase imaginator -dir ./raw -out ./web -width 800 -lowercase # Normalize mixed batch (jpg+png+webp) all to jpg imaginator -dir ./raw -out ./web -width 1920 -format jpg ───────────────────────────────────────────────────────────────────────────── "I do not wish to walk on water." — Hermann Hesse, Siddhartha ───────────────────────────────────────────────────────────────────────────── `, version) } }