You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

790 lines
29 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
——————————————————————————————————————————————————————————————————————————
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 (
"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.3.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 // normalizar nombre de salida a minúsculas
Uppercase bool // normalizar nombre de salida a mayúsculas
}
// 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
}
type ImageSummary struct {
Files []string
ByExt map[string]int
ReadOnlyExts []string // extensiones presentes que son sólo lectura
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 creando directorio de salida: %v", err)
}
summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter)
if err != nil {
log.Fatalf("Error leyendo directorio: %v", err)
}
if len(summary.Files) == 0 {
fmt.Println(" No se encontraron imágenes para procesar.")
return
}
// Resumen de tipos encontrados
fmt.Printf(" Encontradas %d imágenes:\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 += " ⚠ sólo lectura"
}
fmt.Println(line)
}
}
if len(summary.ReadOnlyExts) > 0 && cfg.Format == "" {
joined := strings.Join(summary.ReadOnlyExts, ", ")
fmt.Println()
fmt.Printf(" AVISO: el lote contiene formatos sólo lectura (%s).\n", joined)
fmt.Println(" Los ficheros de salida en ese formato pueden quedar corruptos.")
fmt.Println(" Añade -formato jpg o -formato png para convertirlos.")
}
fmt.Println()
if cfg.DryRun {
fmt.Println(" [dry-run] Rutas de salida:")
fmt.Println()
for _, f := range summary.Files {
out := buildOutputPath(f, cfg)
fmt.Printf(" %s → %s\n", f, out)
}
fmt.Println()
return
}
fmt.Printf(" Procesando con %d workers...\n\n", cfg.Workers)
processImages(summary.Files, cfg)
}
// ─────────────────────────────────────────────────────────────────────────────
// Flags y validación
// ─────────────────────────────────────────────────────────────────────────────
func parseFlags() *Config {
cfg := &Config{}
flag.StringVar(&cfg.InputDir, "dir", "", "Directorio de origen")
flag.StringVar(&cfg.OutputDir, "out", "", "Directorio de destino")
flag.IntVar(&cfg.Width, "width", 0, "Ancho objetivo")
flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo")
flag.IntVar(&cfg.Quality, "calidad", 85, "Calidad JPEG (1-100)")
flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png, tif")
flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro: lanczos, cubic, linear, box")
flag.IntVar(&cfg.Workers, "workers", 4, "Workers concurrentes")
flag.BoolVar(&cfg.Verbose, "verbose", false, "Información detallada")
flag.BoolVar(&cfg.KeepStructure, "estructura", false, "Mantener estructura de subdirectorios")
flag.StringVar(&cfg.Prefix, "prefijo", "", "Prefijo para el nombre de salida")
flag.StringVar(&cfg.Suffix, "sufijo", "", "Sufijo para el nombre de salida (antes de la extensión)")
flag.BoolVar(&cfg.DryRun, "dry-run", false, "Mostrar qué se procesaría sin ejecutar nada")
flag.StringVar(&cfg.Replace, "replace", "", "Sustituir tokens en nombre: orig/dest[,orig2/dest2] (case sensitive)")
flag.StringVar(&cfg.ReplaceI, "replacei", "", "Sustituir tokens en nombre: orig/dest[,...] (case insensitive)")
flag.StringVar(&cfg.ReplaceP, "replacep", "", "Sustituir tokens en nombre: orig/dest[,...] (insensitive + preserve case)")
flag.StringVar(&cfg.FileFilter, "filter", "", "Glob de nombre de fichero a procesar, ej: foto_*.jpg")
flag.BoolVar(&cfg.Lowercase, "lowercase", false, "Normalizar nombre de salida a minúsculas")
flag.BoolVar(&cfg.Uppercase, "uppercase", false, "Normalizar nombre de salida a mayúsculas")
configurarAyuda()
flag.Parse()
return cfg
}
func validateConfig(cfg *Config) error {
if cfg.InputDir == "" || cfg.OutputDir == "" {
flag.Usage()
return fmt.Errorf("faltan parámetros obligatorios: -dir y -out son requeridos")
}
if cfg.Width == 0 && cfg.Height == 0 {
return fmt.Errorf("debe especificar al menos -width o -height")
}
if cfg.Width < 0 || cfg.Height < 0 {
return fmt.Errorf("las dimensiones no pueden ser negativas")
}
if cfg.Quality < 1 || cfg.Quality > 100 {
return fmt.Errorf("la calidad debe estar entre 1 y 100")
}
if cfg.Format != "" &&
cfg.Format != "jpg" && cfg.Format != "jpeg" &&
cfg.Format != "png" &&
cfg.Format != "tif" && cfg.Format != "tiff" {
return fmt.Errorf("formato de salida no soportado: %s (opciones: jpg, png, tif)", cfg.Format)
}
absInput, err := filepath.Abs(cfg.InputDir)
if err != nil {
return fmt.Errorf("error resolviendo ruta de entrada: %v", err)
}
absOutput, err := filepath.Abs(cfg.OutputDir)
if err != nil {
return fmt.Errorf("error resolviendo ruta de salida: %v", err)
}
if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" {
return fmt.Errorf(
"el directorio de entrada y salida no pueden ser el mismo\n" +
" consejo: usa -prefijo o -sufijo para procesar en el mismo directorio sin sobreescribir los originales")
}
if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) {
return fmt.Errorf("el directorio de entrada no existe: %s", cfg.InputDir)
}
if cfg.Workers < 1 {
cfg.Workers = 1
}
// Validar pares de sustitución
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("par de sustitución inválido %q — formato esperado: original/destino", pair)
}
}
}
// Validar glob de filtro
if cfg.FileFilter != "" {
if _, err := filepath.Match(cfg.FileFilter, ""); err != nil {
return fmt.Errorf("patrón -filter inválido %q: %v", cfg.FileFilter, err)
}
}
if cfg.Lowercase && cfg.Uppercase {
return fmt.Errorf("-lowercase y -uppercase son mutuamente excluyentes")
}
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)
// Normalización de case — se aplica sobre el stem ya transformado,
// pero nunca sobre la extensión (evita .JPG o .TIF en sistemas sensibles)
switch {
case cfg.Lowercase:
stem = strings.ToLower(stem)
case cfg.Uppercase:
stem = strings.ToUpper(stem)
}
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()
)
jobs := make(chan string, 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)
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)
wg.Wait()
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(" Procesamiento completado en %s\n\n", elapsed)
fmt.Printf(" Imágenes %d total / %d exitosas / %d errores\n",
total, ok, errCount.Load())
fmt.Printf(" Tamaño %.2f MB entrada → %.2f MB salida (%s reducción)\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),
}
// Tamaño de entrada
if fi, err := os.Stat(inputPath); err == nil {
result.InputSize = fi.Size()
}
outputPath := buildOutputPath(inputPath, cfg)
result.OutputPath = outputPath
// Crear directorios intermedios
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
result.Error = fmt.Errorf("error creando directorio: %v", err)
return result
}
// Abrir imagen
f, err := os.Open(inputPath)
if err != nil {
result.Error = fmt.Errorf("error abriendo archivo: %v", err)
return result
}
defer f.Close()
img, srcFormat, err := image.Decode(f)
if err != nil {
result.Error = fmt.Errorf("error decodificando imagen: %v", err)
return result
}
if cfg.Verbose {
bounds := img.Bounds()
log.Printf(" %s: formato=%s tamaño=%dx%d",
result.Filename, srcFormat, bounds.Dx(), bounds.Dy())
}
// Redimensionar
resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter))
// Guardar
out, err := os.Create(outputPath)
if err != nil {
result.Error = fmt.Errorf("error creando archivo de salida: %v", err)
return result
}
defer func() {
if cerr := out.Close(); cerr != nil && result.Error == nil {
result.Error = cerr
}
}()
outputFormat := cfg.Format
if outputFormat == "" {
ext := strings.ToLower(filepath.Ext(outputPath))
outputFormat = strings.TrimPrefix(ext, ".")
}
switch outputFormat {
case "jpg", "jpeg":
err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality})
case "png":
err = png.Encode(out, resized)
case "tif", "tiff":
err = tiff.Encode(out, resized, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
case "webp", "bmp":
err = fmt.Errorf("formato %s sólo soportado en lectura — usa -formato jpg, png o tif", outputFormat)
default:
err = fmt.Errorf("formato de salida desconocido: %s", outputFormat)
}
if err != nil {
result.Error = fmt.Errorf("error guardando imagen: %v", err)
return result
}
// Tamaño de salida
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
}
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return "…" + s[len(s)-(max-1):]
}
// ─────────────────────────────────────────────────────────────────────────────
// Sustitución de tokens en nombres de fichero
// ─────────────────────────────────────────────────────────────────────────────
// 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.Printf(`
██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
IMAGINATOR VER 2.4.0 Juan V. Navarro Leal
──────────────────────────────────────────────────────────────────────────────
ActiveThing activething.com/Imaginator ©2026 ActiveThing
──────────────────────────────────────────────────────────────────────────────
USO
imaginator -dir <origen> -out <destino> { -width N | -height N } [opciones]
PARÁMETROS OBLIGATORIOS
-dir string Directorio de origen con las imágenes
-out string Directorio de destino para imágenes procesadas
Al menos uno de los siguientes:
-width int Ancho objetivo (mantiene proporción si -height no se especifica)
-height int Alto objetivo (mantiene proporción si -width no se especifica)
FORMATOS SOPORTADOS
Lectura jpg · jpeg · png · tif · tiff · bmp · webp
Escritura jpg · png · tif
(bmp y webp son sólo lectura — usa -formato jpg, png o tif para convertir)
OPCIONES DE IMAGEN
-calidad int Calidad JPEG 1–100 [85]
-formato string Formato de salida: jpg, png, tif [mantener original]
-filtro string Filtro: lanczos · cubic · linear · box [lanczos]
OPCIONES DE NOMBRADO
-prefijo string Prefijo para el nombre de salida [""]
-sufijo string Sufijo para el nombre de salida [""]
-lowercase Normalizar nombre de salida a minúsculas [false]
-uppercase Normalizar nombre de salida a mayúsculas [false]
-replace string Sustituir token en nombre: orig/dest[,orig2/dest2]
case sensitive: imagen→picture, Imagen→Imagen
-replacei string Igual que -replace pero case insensitive
imagen→picture, Imagen→picture, IMAGEN→picture
-replacep string Igual que -replacei pero preserva capitalización
imagen→picture, Imagen→Picture, IMAGEN→PICTURE
OPCIONES DE FILTRADO
-filter string Glob sobre el nombre del fichero a procesar [""]
ej: "foto_*.jpg" "DSC_????.png" "*.webp"
OPCIONES DE EJECUCIÓN
-workers int Workers concurrentes [4]
-estructura Mantener estructura de subdirectorios [false]
-dry-run Mostrar qué se procesaría sin ejecutar [false]
-verbose Información detallada por imagen [false]
EJEMPLOS
# Redimensionar a 800 px de ancho, mantener proporción
imaginator -dir ./fotos -out ./thumbs -width 800
# Convertir todo a JPG 1920×1080 con calidad 90
imaginator -dir ./raw -out ./web -width 1920 -height 1080 -formato jpg -calidad 90
# 8 workers, preservar árbol de carpetas, calidad 85
imaginator -dir ./raw -out ./web -width 1024 -workers 8 -estructura -calidad 85
# Procesar en el mismo directorio añadiendo sufijo "_800w"
imaginator -dir ./fotos -out ./fotos -width 800 -sufijo _800w
# Procesar sólo ficheros que coincidan con un glob
imaginator -dir ./raw -out ./web -width 1920 -filter "DSC_*.jpg"
# Renombrar tokens en los ficheros de salida (case sensitive)
imaginator -dir ./raw -out ./web -width 800 -replace "imagen/picture,foto/photo"
# Renombrar preservando capitalización
imaginator -dir ./raw -out ./web -width 800 -replacep "imagen/picture"
# imagen_001.jpg → picture_001.jpg
# Imagen_001.jpg → Picture_001.jpg
# IMAGEN_001.jpg → PICTURE_001.jpg
# Normalizar todos los nombres de salida a minúsculas
imaginator -dir ./raw -out ./web -width 800 -lowercase
# Normalizar lote mixto (jpg+png+webp) todo a jpg
imaginator -dir ./raw -out ./web -width 1920 -formato jpg
─────────────────────────────────────────────────────────────────────────────
"No deseo caminar sobre el agua." — Hermann Hesse, Siddhartha
─────────────────────────────────────────────────────────────────────────────
`, version)
}
}

Powered by TurnKey Linux.