diff --git a/.gitignore b/.gitignore index beffbd4..420c75f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ test.* encoded.* decoded.* ffwebp.exe -ffwebp \ No newline at end of file +/ffwebp \ No newline at end of file diff --git a/cmd/ffwebp/main.go b/cmd/ffwebp/main.go index 5fc614b..1e696f5 100644 --- a/cmd/ffwebp/main.go +++ b/cmd/ffwebp/main.go @@ -2,9 +2,11 @@ package main import ( "context" + "fmt" "io" "os" "path/filepath" + "strings" "time" _ "github.com/coalaura/ffwebp/internal/builtins" @@ -23,15 +25,20 @@ func main() { &cli.StringFlag{ Name: "input", Aliases: []string{"i"}, - Usage: "input file (\"-\" = stdin)", + Usage: "input file or pattern (\"-\" = stdin, supports globs and %d sequences)", Value: "-", }, &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, - Usage: "output file (\"-\" = stdout)", + Usage: "output file, directory, or pattern (\"-\" = stdout)", Value: "-", }, + &cli.IntFlag{ + Name: "start-number", + Usage: "starting number for %%d output patterns", + Value: 1, + }, &cli.StringFlag{ Name: "codec", Aliases: []string{"c"}, @@ -97,17 +104,113 @@ func run(_ context.Context, cmd *cli.Command) error { banner() var ( - n int input string output string common opts.Common + ) + common.Quality = cmd.Int("quality") + common.Lossless = cmd.Bool("lossless") + + common.FillDefaults() + + input = cmd.String("input") + output = cmd.String("output") + + var inputs []string + + if input == "-" { + inputs = []string{"-"} + } else if hasSeq(input) { + files, err := expandSeq(input) + if err != nil { + return err + } + + if len(files) == 0 { + return fmt.Errorf("no inputs match sequence: %s", filepath.ToSlash(input)) + } + + inputs = files + } else if isGlob(input) { + files, err := expandGlob(input) + if err != nil { + return err + } + + if len(files) == 0 { + return fmt.Errorf("no inputs match glob: %s", filepath.ToSlash(input)) + } + + inputs = files + } else { + inputs = []string{input} + } + + startNum := cmd.Int("start-number") + + var outputs []string + + if len(inputs) == 1 { + outputs = []string{output} + } else { + switch { + case output == "-": + return fmt.Errorf("multiple inputs require an output pattern or directory, not '-' ") + case hasSeq(output): + outs := make([]string, len(inputs)) + + for i := range inputs { + outs[i] = formatSeq(output, i, startNum) + } + + outputs = outs + default: + outDir := output + if outDir == "" { + outDir = "." + } + + if fi, err := os.Stat(outDir); err == nil { + if !fi.IsDir() { + return fmt.Errorf("output must be a directory or pattern when multiple inputs: %s", filepath.ToSlash(output)) + } + } else { + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + + outs := make([]string, len(inputs)) + + for i := range inputs { + outs[i] = output + } + + outputs = outs + } + } + + for i := range inputs { + in := inputs[i] + out := outputs[i] + + if err := processOne(in, out, cmd, &common); err != nil { + return err + } + } + + return nil +} + +func processOne(input, output string, cmd *cli.Command, common *opts.Common) error { + var ( reader io.Reader = os.Stdin writer *countWriter = &countWriter{w: os.Stdout} ) - if input = cmd.String("input"); input != "-" { + if input != "-" { logx.Printf("opening input file %q\n", filepath.ToSlash(input)) file, err := os.OpenFile(input, os.O_RDONLY, 0) @@ -122,16 +225,60 @@ func run(_ context.Context, cmd *cli.Command) error { logx.Printf("reading input from \n") } - sniffed, reader, err := codec.Sniff(reader, input, cmd.Bool("sniff")) + sniffed, reader2, err := codec.Sniff(reader, input, cmd.Bool("sniff")) if err != nil { return err } + reader = reader2 + logx.Printf("sniffed codec: %s (%q)\n", sniffed.Codec, sniffed) - if output = cmd.String("output"); output != "-" { + var mappedFromDir bool + + if output != "-" { + if fi, err := os.Stat(output); err == nil && fi.IsDir() { + name := filepath.Base(input) + output = filepath.Join(output, name) + + mappedFromDir = true + } else if strings.HasSuffix(output, string(os.PathSeparator)) { + if err := os.MkdirAll(output, 0755); err != nil { + return err + } + + name := filepath.Base(input) + output = filepath.Join(output, name) + + mappedFromDir = true + } + } + + oCodec, oExt, err := codec.Detect(output, cmd.String("codec")) + if err != nil { + return err + } + + common.OutputExtension = oExt + + logx.Printf("output codec: %s (forced=%v)\n", oCodec, cmd.IsSet("codec")) + + if output != "-" { + curExt := strings.TrimPrefix(filepath.Ext(output), ".") + + if mappedFromDir || curExt == "" || curExt != oExt { + base := strings.TrimSuffix(output, filepath.Ext(output)) + output = base + "." + oExt + } + } + + if output != "-" { logx.Printf("opening output file %q\n", filepath.ToSlash(output)) + if err := os.MkdirAll(filepath.Dir(output), 0755); err != nil { + return err + } + file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err @@ -144,20 +291,6 @@ func run(_ context.Context, cmd *cli.Command) error { logx.Printf("writing output to \n") } - common.Quality = cmd.Int("quality") - common.Lossless = cmd.Bool("lossless") - - common.FillDefaults() - - oCodec, oExt, err := codec.Detect(output, cmd.String("codec")) - if err != nil { - return err - } - - common.OutputExtension = oExt - - logx.Printf("output codec: %s (forced=%v)\n", oCodec, cmd.IsSet("codec")) - t0 := time.Now() img, err := sniffed.Codec.Decode(reader) @@ -177,6 +310,8 @@ func run(_ context.Context, cmd *cli.Command) error { t1 := time.Now() + var n int + img, n, err = effects.ApplyAll(img) if err != nil { return err @@ -186,8 +321,7 @@ func run(_ context.Context, cmd *cli.Command) error { t2 := time.Now() - err = oCodec.Encode(writer, img, common) - if err != nil { + if err := oCodec.Encode(writer, img, *common); err != nil { return err } diff --git a/cmd/ffwebp/pattern.go b/cmd/ffwebp/pattern.go new file mode 100644 index 0000000..6f1382b --- /dev/null +++ b/cmd/ffwebp/pattern.go @@ -0,0 +1,163 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" +) + +func hasSeq(s string) bool { + re := regexp.MustCompile(`%0?\d*d`) + + return re.FindStringIndex(s) != nil +} + +func seqSpec(s string) (start, end, width int, zeroPad bool, ok bool) { + re := regexp.MustCompile(`%0?(\d*)d`) + + loc := re.FindStringSubmatchIndex(s) + if loc == nil { + return 0, 0, 0, false, false + } + + start = loc[0] + end = loc[1] + + if loc[2] != -1 && loc[3] != -1 { + wstr := s[loc[2]:loc[3]] + + if wstr != "" { + if w, err := strconv.Atoi(wstr); err == nil && w > 0 { + width = w + } + } + } + + zeroPad = width > 0 + + return start, end, width, zeroPad, true +} + +func formatSeq(pattern string, idx, startNum int) string { + s, e, width, zero, ok := seqSpec(pattern) + if !ok { + return pattern + } + + n := idx + startNum - 1 + + var num string + + if width > 0 && zero { + num = fmt.Sprintf("%0*d", width, n) + } else { + num = fmt.Sprintf("%d", n) + } + + return pattern[:s] + num + pattern[e:] +} + +func isGlob(s string) bool { + for i := 0; i < len(s); i++ { + switch s[i] { + case '*', '?', '[': + return true + } + } + + return false +} + +func expandGlob(pat string) ([]string, error) { + matches, err := filepath.Glob(pat) + if err != nil { + return nil, err + } + + sort.Strings(matches) + + return matches, nil +} + +func expandSeq(pat string) ([]string, error) { + dir := filepath.Dir(pat) + if dir == "." || dir == "" { + dir = "" + } + + base := filepath.Base(pat) + + s, e, width, _, ok := seqSpec(base) + if !ok { + return nil, nil + } + + pre := regexp.QuoteMeta(base[:s]) + suf := regexp.QuoteMeta(base[e:]) + + num := `\\d+` + + if width > 0 { + num = fmt.Sprintf(`\\d{%d}`, width) + } + + re := regexp.MustCompile("^" + pre + "(" + num + ")" + suf + "$") + + scanDir := filepath.Dir(pat) + + if scanDir == "." || scanDir == "" { + scanDir = "." + } + + entries, err := os.ReadDir(scanDir) + if err != nil { + return nil, err + } + + type match struct { + path string + n int + } + + var out []match + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + + m := re.FindStringSubmatch(name) + + if m == nil { + continue + } + + n, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + + full := name + + if dir != "" { + full = filepath.Join(filepath.Dir(pat), name) + } + + out = append(out, match{path: full, n: n}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].n < out[j].n }) + + result := make([]string, len(out)) + + for i := range out { + result[i] = out[i].path + } + + return result, nil +}