/* —————————————————————————————————————————————————————————————————————————— Proyecto ImgResizer ————————————————————————————————————————————————————————————————————————————— Fichero main.go2 Package ImgResizer Autor Juan V. Navarro juanvnl@activething.com Creado 26/01/2026 Mejorado 26/01/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. You may combine this software with other code, provided that such combination does not circumvent the restrictions of this License. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of this software. ————————————————————————————————————————————————————————————————————————————— Web : activething.com | activething.com/ImgResizer git : g.activething.com | github.com/activething/ImgResizer 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" "strings" "sync" "sync/atomic" "github.com/disintegration/imaging" _ "golang.org/x/image/webp" ) type Config struct { InputDir string OutputDir string Width int Height int Quality int Format string Filter string Workers int Verbose bool KeepStructure bool } type ProcessResult struct { Filename string Success bool Error error } 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) } files, err := collectImageFiles(cfg.InputDir, cfg.KeepStructure) if err != nil { log.Fatalf("Error leyendo directorio: %v", err) } if len(files) == 0 { log.Println("No se encontraron imágenes para procesar") return } fmt.Printf("Encontradas %d imágenes. Procesando con %d workers...\n\n", len(files), cfg.Workers) processImages(files, cfg) } 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 (mantiene proporción si height=0)") flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo (mantiene proporción si width=0)") flag.IntVar(&cfg.Quality, "calidad", 100, "Calidad JPEG (1-100)") flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png (vacío=mantener original)") flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro de redimensionado: lanczos, cubic, linear, box") flag.IntVar(&cfg.Workers, "workers", 4, "Número de workers concurrentes") flag.BoolVar(&cfg.Verbose, "verbose", false, "Mostrar información detallada") flag.BoolVar(&cfg.KeepStructure, "estructura", false, "Mantener estructura de subdirectorios") configurarAyuda() flag.Parse() return cfg } func validateConfig(cfg *Config) error { if cfg.InputDir == "" || cfg.OutputDir == "" { flag.Usage() return fmt.Errorf("faltan parámetros obligatorios") } 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" { return fmt.Errorf("formato no soportado: %s (use jpg o png)", 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 { return fmt.Errorf("el directorio de entrada y salida no pueden ser el mismo") } 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 } return nil } func collectImageFiles(root string, keepStructure bool) ([]string, error) { var files []string validExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, } 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] { files = append(files, path) } return nil }) return files, err } func processImages(files []string, cfg *Config) { var ( wg sync.WaitGroup processed atomic.Int32 errors atomic.Int32 total = int32(len(files)) ) jobs := make(chan string, len(files)) results := make(chan ProcessResult, len(files)) // Iniciar workers for i := 0; i < cfg.Workers; i++ { wg.Add(1) go func() { defer wg.Done() for inputPath := range jobs { result := processImage(inputPath, cfg) results <- result current := processed.Add(1) if result.Success { if cfg.Verbose { fmt.Printf("[%d/%d] ✓ %s\n", current, total, result.Filename) } } else { errors.Add(1) fmt.Printf("[%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) } } }() } // Enviar trabajos for _, file := range files { jobs <- file } close(jobs) // Esperar completar wg.Wait() close(results) // Resumen final fmt.Printf("\n═══════════════════════════════════════════════════════\n") fmt.Printf("Procesamiento completado:\n") fmt.Printf(" Total: %d imágenes\n", total) fmt.Printf(" Exitosas: %d\n", total-errors.Load()) fmt.Printf(" Errores: %d\n", errors.Load()) fmt.Printf("═══════════════════════════════════════════════════════\n") } func processImage(inputPath string, cfg *Config) ProcessResult { result := ProcessResult{ Filename: filepath.Base(inputPath), Success: false, } // Calcular ruta de salida relPath, err := filepath.Rel(cfg.InputDir, inputPath) if err != nil { result.Error = fmt.Errorf("error calculando ruta relativa: %v", err) return result } outputPath := filepath.Join(cfg.OutputDir, relPath) // Cambiar extensión si se especificó formato if cfg.Format != "" { ext := filepath.Ext(outputPath) outputPath = outputPath[:len(outputPath)-len(ext)] + "." + cfg.Format } // Crear subdirectorios si es necesario 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, format, 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(" Formato original: %s, Dimensiones: %dx%d", format, bounds.Dx(), bounds.Dy()) } // Redimensionar filter := getResampleFilter(cfg.Filter) resized := imaging.Resize(img, cfg.Width, cfg.Height, 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 } }() // Determinar formato de salida outputFormat := cfg.Format if outputFormat == "" { outputFormat = strings.ToLower(filepath.Ext(outputPath)[1:]) } switch outputFormat { case "jpg", "jpeg": err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality}) case "png": err = png.Encode(out, resized) case "webp": err = fmt.Errorf("escritura de WebP requiere CGO (use -formato jpg o png para convertir)") default: err = imaging.Save(resized, outputPath) } if err != nil { result.Error = fmt.Errorf("error guardando imagen: %v", err) return result } result.Success = true return result } 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 configurarAyuda() { flag.Usage = func() { fmt.Printf("\n╔═══════════════════════════════════════════════════════════════╗\n") fmt.Printf("║ ImgResizer - Redimensionador de Imágenes ║\n") fmt.Printf("╚═══════════════════════════════════════════════════════════════╝\n\n") fmt.Printf("Uso: resizer -dir -out [-width | -height] [opciones]\n\n") fmt.Printf("Parámetros obligatorios:\n") fmt.Printf(" -dir string\n") fmt.Printf(" Directorio de origen con las imágenes\n") fmt.Printf(" -out string\n") fmt.Printf(" Directorio de destino para imágenes procesadas\n") fmt.Printf(" -width int\n") fmt.Printf(" Ancho objetivo (mantiene proporción si height no se especifica)\n") fmt.Printf(" -height int\n") fmt.Printf(" Alto objetivo (mantiene proporción si width no se especifica)\n\n") fmt.Printf("Opciones:\n") flag.PrintDefaults() fmt.Printf("\nEjemplos:\n") fmt.Printf(" # Redimensionar a 800px de ancho manteniendo proporción\n") fmt.Printf(" resizer -dir ./input -out ./output -width 800\n\n") fmt.Printf(" # Convertir todas las imágenes a JPG de 1920x1080\n") fmt.Printf(" resizer -dir ./input -out ./output -width 1920 -height 1080 -formato jpg\n\n") fmt.Printf(" # Procesar con 8 workers y mantener estructura de carpetas\n") fmt.Printf(" resizer -dir ./input -out ./output -width 1024 -workers 8 -estructura\n\n") } }