commit 10867d2d8581f25f1d662e6b6977c4b87c2f79a3 Author: Aurélie Delhaie Date: Wed Jun 21 22:57:47 2023 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adb36c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.exe \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f513cf --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# HEIC2JPG \ No newline at end of file diff --git a/exif/exif.go b/exif/exif.go new file mode 100644 index 0000000..1f6eeb1 --- /dev/null +++ b/exif/exif.go @@ -0,0 +1,55 @@ +package exif + +import "io" + +type ( + writerSkipper struct { + io.Writer + bytesToSkip int + } +) + +func NewWriterExif(w io.Writer, exif []byte) (io.Writer, error) { + writer := &writerSkipper{ + Writer: w, + bytesToSkip: 2, + } + soi := []byte{0xff, 0xd8} + if _, err := w.Write(soi); err != nil { + return nil, err + } + + if exif != nil { + app1Marker := 0xe1 + markerlen := 2 + len(exif) + marker := []byte{0xff, uint8(app1Marker), uint8(markerlen >> 8), uint8(markerlen & 0xff)} + if _, err := w.Write(marker); err != nil { + return nil, err + } + + if _, err := w.Write(exif); err != nil { + return nil, err + } + } + + return writer, nil +} + +func (w *writerSkipper) Write(data []byte) (int, error) { + if w.bytesToSkip <= 0 { + return w.Writer.Write(data) + } + + if dataLen := len(data); dataLen < w.bytesToSkip { + w.bytesToSkip -= dataLen + return dataLen, nil + } + + if n, err := w.Writer.Write(data[w.bytesToSkip:]); err == nil { + n += w.bytesToSkip + w.bytesToSkip = 0 + return n, nil + } else { + return n, err + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d702e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/mojitaurelie/heic2jpg + +go 1.20 + +require github.com/adrium/goheif v0.0.0-20230113233934-ca402e77a786 + +require github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cfbf2ba --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/adrium/goheif v0.0.0-20230113233934-ca402e77a786 h1:zvgtcRb2B5gynWjm+Fc9oJZPHXwmcgyH0xCcNm6Rmo4= +github.com/adrium/goheif v0.0.0-20230113233934-ca402e77a786/go.mod h1:aKVJoQ0cc9K5Xb058XSnnAxXLliR97qbSqWBlm5ca1E= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5a5dee9 --- /dev/null +++ b/main.go @@ -0,0 +1,248 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "image/jpeg" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/adrium/goheif" + "github.com/mojitaurelie/heic2jpg/exif" +) + +type ( + filer interface { + IsDir() bool + Name() string + } +) + +func usage() { + fmt.Printf("Usage: %s [OPTIONS] \n\n", os.Args[0]) + fmt.Printf("Note: is ignored when -dir is set\n\nOPTIONS:\n") + flag.PrintDefaults() +} + +func main() { + go func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, "E: ", r) + os.Exit(-1) + } + }() + flag.Usage = usage + dir := flag.String("dir", "", "Directory to analyse") + recursive := flag.Bool("r", false, "Analyzes the directory recursively (Require -dir)") + jpegQuality := flag.Int("q", jpeg.DefaultQuality, "Define the jpeg quality") + ignoreErr := flag.Bool("ignore-errors", false, "Continue to convert when the converter encounter an error") + removeOrig := flag.Bool("remove-original", false, "Remove the heic file after conversion") + outPath := flag.String("o", "", "Output directory") + flag.Parse() + + if *jpegQuality < 1 || *jpegQuality > 100 { + fmt.Println("W: Illegal value for jpeg quality, quality is set to ", jpeg.DefaultQuality) + *jpegQuality = jpeg.DefaultQuality + } + + var files []string + if len(*dir) == 0 { + if len(os.Args) == 1 { + fmt.Fprintln(os.Stderr, "E: Missing file path") + os.Exit(-1) + } + files = append(files, os.Args[len(os.Args)-1]) + } else { + var err error + files, err = getHEICFiles(*dir, *recursive) + if err != nil { + fmt.Fprintf(os.Stderr, "E: Failed to analyse directory: %v\n", err) + os.Exit(-1) + } + } + if len(files) == 0 { + fmt.Println("No heic file found") + return + } + + fmt.Printf("%d files found with the extension .heic\n\n", len(files)) + + if *removeOrig { + fmt.Printf("WARNING: all the .heic will be deleted after the convertion\n\n") + } + + if askForConfirmation("Do you want to start the conversion?") { + out := *dir + overwritePath := false + if len(*outPath) > 0 { + out = *outPath + overwritePath = true + } + start := time.Now() + converted := convert(out, files, *ignoreErr, *jpegQuality, overwritePath) + + if *removeOrig { + for _, file := range converted { + err := os.Remove(file) + if err != nil { + fmt.Fprintf(os.Stderr, "E: Failed to remove %s: %v\n", file, err) + } + } + } + + fmt.Printf("%d file(s) converted in %v\n", len(converted), time.Since(start)) + } +} + +func getHEICFiles(dir string, rec bool) ([]string, error) { + var res []string + i, err := os.Stat(dir) + if err != nil { + return nil, err + } + if !i.IsDir() { + return nil, fmt.Errorf("%s is not a directory", dir) + } + if rec { + err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { + if isHEIC(info) { + res = append(res, path) + } + return nil + }) + if err != nil { + return nil, err + } + return res, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, info := range entries { + if isHEIC(info) { + res = append(res, filepath.Join(dir, info.Name())) + } + } + return res, nil +} + +func isHEIC(f filer) bool { + if f == nil { + return false + } + return !f.IsDir() && strings.HasSuffix(f.Name(), ".heic") +} + +func convert(out string, files []string, ignoreErr bool, jpegQuality int, overwritePath bool) []string { + var success []string + wg := new(sync.WaitGroup) + chunk := chunkWork(files) + for _, files := range chunk { + wg.Add(1) + go func(files []string, wg *sync.WaitGroup) { + for _, file := range files { + fi, err := os.Open(file) + if err != nil { + fmt.Fprintf(os.Stderr, "E: Failed to open %s: %v\n", file, err) + if ignoreErr { + continue + } + os.Exit(-1) + } + + ex, err := goheif.ExtractExif(fi) + if err != nil { + fmt.Printf("W: no EXIF from %s: %v\n", file, err) + } + + img, err := goheif.Decode(fi) + if err != nil { + fi.Close() + fmt.Fprintf(os.Stderr, "E: Failed to decode %s: %v\n", file, err) + if ignoreErr { + continue + } + os.Exit(-1) + } + + fout := strings.TrimSuffix(file, ".heic") + ".jpg" + if overwritePath { + fout = filepath.Join(out, filepath.Base(fout)) + } + + fo, err := os.OpenFile(fout, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + fi.Close() + fmt.Fprintf(os.Stderr, "E: Failed to create output file %s: %v\n", fout, err) + if ignoreErr { + continue + } + os.Exit(-1) + } + + w, _ := exif.NewWriterExif(fo, ex) + err = jpeg.Encode(w, img, &jpeg.Options{ + Quality: jpegQuality, + }) + if err != nil { + fi.Close() + fo.Close() + fmt.Fprintf(os.Stderr, "E: Failed to encode %s: %v\n", fout, err) + if ignoreErr { + continue + } + os.Exit(-1) + } + fi.Close() + fo.Close() + success = append(success, file) + } + wg.Done() + }(files, wg) + } + wg.Wait() + return success +} + +func chunkWork(files []string) [][]string { + var divided [][]string + chunkSize := (len(files) + runtime.NumCPU() - 1) / runtime.NumCPU() + for i := 0; i < len(files); i += chunkSize { + end := i + chunkSize + + if end > len(files) { + end = len(files) + } + + divided = append(divided, files[i:end]) + } + return divided +} + +func askForConfirmation(s string) bool { + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("%s [y/n]: ", s) + + response, err := reader.ReadString('\n') + if err != nil { + panic(err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + + if response == "y" || response == "yes" { + return true + } else if response == "n" || response == "no" { + return false + } + } +}