|
|
/*
|
|
|
——————————————————————————————————————————————————————————————————————————
|
|
|
Proyecto Imaginator
|
|
|
|
|
|
—————————————————————————————————————————————————————————————————————————
|
|
|
|
|
|
Fichero cmd/cli/main.go
|
|
|
Package main (CLI)
|
|
|
Autor Juan V. Navarro juanvnl@activething.com
|
|
|
Creado 26/01/2026
|
|
|
Mejorado 20/03/2026
|
|
|
|
|
|
—————————————————————————————————————————————————————————————————————————
|
|
|
|
|
|
This software is licensed under the Elastic License v2.0.
|
|
|
https://activething.com/ATGO/licenses
|
|
|
|
|
|
Web : activething.com/Imaginator
|
|
|
Correo : dev@activething.com
|
|
|
|
|
|
"I do not wish to walk on water." — Hermann Hesse, Siddhartha
|
|
|
|
|
|
Copyright (c) 2026 Active Thing
|
|
|
|
|
|
—————————————————————————————————————————————————————————————————————————
|
|
|
*/
|
|
|
package main
|
|
|
|
|
|
import (
|
|
|
"flag"
|
|
|
"fmt"
|
|
|
"log"
|
|
|
"os"
|
|
|
"path/filepath"
|
|
|
"strings"
|
|
|
"time"
|
|
|
|
|
|
"imaginator/core"
|
|
|
)
|
|
|
|
|
|
func main() {
|
|
|
cfg := parseFlags()
|
|
|
|
|
|
if err := core.Validate(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 := core.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 := core.BuildOutputPath(f, cfg)
|
|
|
fmt.Printf(" %s → %s\n", f, out)
|
|
|
}
|
|
|
fmt.Println()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
fmt.Printf(" Processing with %d workers...\n\n", cfg.Workers)
|
|
|
|
|
|
var (
|
|
|
ok int32
|
|
|
errCount int32
|
|
|
totalIn int64
|
|
|
totalOut int64
|
|
|
start = time.Now()
|
|
|
)
|
|
|
|
|
|
total := int32(len(summary.Files))
|
|
|
|
|
|
for p := range core.Run(summary, cfg) {
|
|
|
r := p.Result
|
|
|
if r.Success {
|
|
|
ok++
|
|
|
totalIn += r.InputSize
|
|
|
totalOut += r.OutputSize
|
|
|
if cfg.Verbose {
|
|
|
fmt.Printf(" [%d/%d] ✓ %-40s → %-40s (%s)\n",
|
|
|
p.Current, p.Total,
|
|
|
truncate(r.Filename, 40),
|
|
|
truncate(filepath.Base(r.OutputPath), 40),
|
|
|
r.Duration.Round(time.Millisecond))
|
|
|
} else {
|
|
|
fmt.Printf(" [%d/%d] ✓ %s\n", p.Current, p.Total, r.Filename)
|
|
|
}
|
|
|
} else {
|
|
|
errCount++
|
|
|
fmt.Printf(" [%d/%d] ✗ %s: %v\n", p.Current, p.Total, r.Filename, r.Error)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
elapsed := time.Since(start).Round(time.Millisecond)
|
|
|
inMB := float64(totalIn) / 1024 / 1024
|
|
|
outMB := float64(totalOut) / 1024 / 1024
|
|
|
|
|
|
var saving string
|
|
|
if totalIn > 0 {
|
|
|
pct := 100.0 - (float64(totalOut)/float64(totalIn))*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)
|
|
|
fmt.Printf(" Size %.2f MB in → %.2f MB out (%s saved)\n", inMB, outMB, saving)
|
|
|
fmt.Printf(" ══════════════════════════════════════════════════════\n\n")
|
|
|
}
|
|
|
|
|
|
func parseFlags() *core.Config {
|
|
|
cfg := core.DefaultConfig()
|
|
|
|
|
|
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", cfg.Quality, "JPEG quality (1-100)")
|
|
|
flag.StringVar(&cfg.Format, "format", "", "Output format: jpg, png, tif")
|
|
|
flag.StringVar(&cfg.Resample, "resample", cfg.Resample, "Resampling filter: lanczos, cubic, linear, box")
|
|
|
flag.IntVar(&cfg.Workers, "workers", cfg.Workers, "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: old/new[,...] (case sensitive)")
|
|
|
flag.StringVar(&cfg.ReplaceI, "replacei", "", "Replace tokens in filename: old/new[,...] (case insensitive)")
|
|
|
flag.StringVar(&cfg.ReplaceP, "replacep", "", "Replace tokens in filename: old/new[,...] (insensitive+preserve case)")
|
|
|
flag.StringVar(&cfg.FileFilter, "filter", "", "Glob pattern to select input files")
|
|
|
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 = extension, default: b64)")
|
|
|
flag.StringVar(&cfg.TSFile, "ts", "", "Write TypeScript file with all images as base64 data URIs")
|
|
|
flag.StringVar(&cfg.NoSpaces, "nospaces", "", "Replace spaces in filename (optional value = replacement char, default: _)")
|
|
|
|
|
|
setupHelp()
|
|
|
flag.Parse()
|
|
|
|
|
|
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 truncate(s string, max int) string {
|
|
|
if len(s) <= max {
|
|
|
return s
|
|
|
}
|
|
|
return "…" + s[len(s)-(max-1):]
|
|
|
}
|
|
|
|
|
|
func setupHelp() {
|
|
|
flag.Usage = func() {
|
|
|
fmt.Printf(`
|
|
|
██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
|
|
|
██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
|
|
|
██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
|
|
|
██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
|
|
|
██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
|
|
|
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
|
|
|
|
|
|
─────────────────────────────────────────────────────────────────────────────
|
|
|
IMAGINATOR VER %s
|
|
|
|
|
|
ActiveThing activething.com/Imaginator ©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]
|
|
|
-ts string Write TypeScript file with all images as base64 data URIs
|
|
|
(-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; optional value = replacement char [_]
|
|
|
-replace string Replace token: old/new[,old2/new2] (case sensitive)
|
|
|
-replacei string Same as -replace but case insensitive
|
|
|
-replacep string Same as -replacei but preserves capitalization
|
|
|
|
|
|
FILTER OPTIONS
|
|
|
-filter string Glob pattern on filename [""]
|
|
|
|
|
|
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
|
|
|
imaginator -dir ./photos -out ./thumbs -width 800
|
|
|
imaginator -dir ./raw -out ./web -width 1920 -height 1080 -format jpg -quality 90
|
|
|
imaginator -dir ./icons -out ./dist -width 64 -format png -ts src/assets/icons.ts
|
|
|
imaginator -dir ./raw -out ./web -width 800 -nospaces -lowercase
|
|
|
|
|
|
─────────────────────────────────────────────────────────────────────────────
|
|
|
"I do not wish to walk on water." — Hermann Hesse, Siddhartha
|
|
|
─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
`, core.Version)
|
|
|
}
|
|
|
}
|