|
|
|
|
|
/*
|
|
|
|
|
|
——————————————————————————————————————————————————————————————————————————
|
|
|
|
|
|
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<string, string> = {\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 <source> -out <dest> { -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<string,string> = { ... }
|
|
|
|
|
|
(-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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|