commit 1c6dfc880d6f3d52f012b3230baf86b6d3606c00 Author: bakonpancakz Date: Sat May 23 17:16:14 2026 -0700 Initial Release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfd6d15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vestige +dependencies +bin +lib \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8762813 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 bakonpancakz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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 NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7da3a35 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ + +# `>_` clitools + +A collection of simple CLI tools which I use ocasionally. +They're really simple, mostly single-file, and should theoretically "just work" across platforms. + +- [`(imageconvert)` Mass Image Converter using ImageMagick](#imageconvert-mass-image-converter-using-imagemagick) +- [`(mediaconvert)` Mass Media Converter using FFMPEG](#mediaconvert-mass-media-converter-using-ffmpeg) +- [`(crunchy)` Turn Image into a Crunchy JPEG](#crunchy-turn-image-into-a-crunchy-jpeg) +- [`(mangapub)` Converts CBZs into EPUBs](#mangapub-converts-cbzs-into-epubs) + +
+ +## `(imageconvert)` Mass Image Converter using ImageMagick +Ever have a directory full of images in different formats? Use this tool to +quickly convert them to a normal extension. Outputs to a `convert` folder +in the current working directory. + +> **Requires:** ImageMagick + +``` +imageconvert + --skip-errors - Skip on conversion error + --skip-resume - Skip Resume Checking + --multithread - Use Multiple Threads + --recursive - Scan Directories Recursively + - File Extension(s) to convert from, delimited with comma + - File Extension to convert into + [Arguments] - Arguments to pass onto ImageMagick +``` + +
+ +## `(mediaconvert)` Mass Media Converter using FFMPEG +Ever have a directory full of videos in different formats? Use this tool to +quickly convert them to a normal extension. Outputs to a `convert` folder +in the current working directory. + +> **Requires:** FFMPEG + +``` +mediaconvert + --skip-resume - Skip Resume Checking + --multithread - Use Multiple Threads + --recursive - Scan Directories Recursively + - File Extension(s) to convert from, delimited with comma + - File Extension to convert into + [Arguments] - Arguments to pass onto FFMPEG +Templates: + {filename} - Full Filename (e.g. myfile.txt) + {basename} - Base Filename (e.g. myfile + {directory} - Source Directory (e.g. /path/to/file) +``` + +
+ +## `(crunchy)` Turn Image into a Crunchy JPEG +Applies random noise and rounding errors to the colorspace to make an image look **"crunchy"** + +``` +crunchy + --noise= - Noise Level (Default: 25, Range: 0-100) + --quality= - JPEG Quality (Default: 0, Range: 0-100) + --generations= - Iterations (Default: 5) + - Input Filename +``` + +

+ +

Another satisfied customer!

+

+ +
+ +## `(mangapub)` Converts CBZs into EPUBs +Converts a directory of CBZ files into EPUBs, designed for copying mass amounts +of manga onto a Kindle 8th gen. It's default settings are very crunchy! + +**Note:** This doesn't properly split large images into two, and it will never, +because it doesn't bother me :P + +``` +mangapub + --extract - Extract Images to Directory + --recursive - Scan Directories Recursively + --height= - Image Height (Default: 800) + --width= - Image Width (Default: 600) + --quality= - JPEG Quality (Default: 25, Range: 0-100) +``` + +> Highly modified version of this repo: https://github.com/DimazzzZ/cbz2epub + +
diff --git a/crunchy/example.png b/crunchy/example.png new file mode 100644 index 0000000..fe135b6 Binary files /dev/null and b/crunchy/example.png differ diff --git a/crunchy/go.mod b/crunchy/go.mod new file mode 100644 index 0000000..a917958 --- /dev/null +++ b/crunchy/go.mod @@ -0,0 +1,5 @@ +module github.com/bakonpancakz/clitools/crunchy + +go 1.25.2 + +require golang.org/x/image v0.33.0 diff --git a/crunchy/go.sum b/crunchy/go.sum new file mode 100644 index 0000000..ba0ff29 --- /dev/null +++ b/crunchy/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= diff --git a/crunchy/main.go b/crunchy/main.go new file mode 100644 index 0000000..6dda63f --- /dev/null +++ b/crunchy/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io" + "math" + "math/rand" + "os" + "path" + "strconv" + "strings" + + "golang.org/x/image/webp" +) + +func main() { + + // ----- Parse Arguments ----- + var optionQuality = 0 + var optionNoise = 25 + var optionGenerations = 5 + var optionFilename string + + flags := make([]string, 0, len(os.Args)) + for i := 1; i < len(os.Args); i++ { + segments := strings.SplitN(os.Args[i], "=", 2) + if len(segments) == 2 { + n := segments[0] + s := segments[1] + switch { + case strings.EqualFold(n, "--generations"): + v := parseInteger(n, s, 0, math.MaxInt) + fmt.Printf("Flag: Generation(s) %d\n", v) + optionGenerations = v + + case strings.EqualFold(n, "--quality"): + v := parseInteger(n, s, 0, 100) + fmt.Printf("Flag: Quality %d\n", v) + optionQuality = v + + case strings.EqualFold(n, "--noise"): + v := parseInteger(n, s, 0, 100) + fmt.Printf("Flag: Noise Level %d\n", v) + optionQuality = v + + default: + fmt.Printf("%s: Unknown Argument", n) + os.Exit(1) + } + + } else { + flags = append(flags, segments[0]) + } + } + if len(flags) < 1 { + fmt.Println("crunchy") + fmt.Println(" --noise= - Noise Level (Default: 25, Range: 0-100)") + fmt.Println(" --quality= - JPEG Quality (Default: 0, Range: 0-100)") + fmt.Println(" --generations= - Iterations (Default: 5)") + fmt.Println(" - Input Filename") + os.Exit(0) + } + optionFilename = flags[0] + noiseInteger := int(float32(optionNoise)*2.56) + 1 + noiseHalved := noiseInteger / 2 + + // ----- Decode Image Contents ----- + content := bytes.Buffer{} + f, err := os.Open(optionFilename) + if err != nil { + fmt.Printf("Failed to open file: %s\n", err.Error()) + os.Exit(1) + } + if _, err := io.Copy(&content, f); err != nil { + fmt.Printf("Failed to read file: %s\n", err.Error()) + os.Exit(1) + } + + img, err := decodeImage(content.Bytes()) + if err != nil { + fmt.Printf("Decoding Error: %s\n", err.Error()) + os.Exit(1) + } + bounds := img.Bounds() + ycc := image.NewYCbCr(bounds, image.YCbCrSubsampleRatio444) + rgb := image.NewRGBA(bounds) + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + rgb.Set(x, y, img.At(x, y)) // copy generic img to rgba + } + } + + // ----- Apply Generation Loss ----- + for i := 0; i < optionGenerations; i++ { + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + // Rounding Error via Colorspace Conversion + r, g, b, _ := rgb.At(x, y).RGBA() + cy, cb, cr := color.RGBToYCbCr(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + + // Random Noise + noise := rand.Intn(noiseInteger) - noiseHalved + cb = uint8(int(cb) + noise) + cr = uint8(int(cr) + noise) + + // Apply Changes + ycc.Y[ycc.YOffset(x, y)] = cy + ycc.Cb[ycc.COffset(x, y)] = cb + ycc.Cr[ycc.COffset(x, y)] = cr + } + } + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + rgb.Set(x, y, ycc.At(x, y)) + } + } + } + + // ----- Write Output ----- + content.Reset() + if err := jpeg.Encode(&content, rgb, &jpeg.Options{Quality: optionQuality}); err != nil { + fmt.Printf("Encoding Error: %s\n", err.Error()) + os.Exit(1) + } + cleanname := path.Base(optionFilename) + emptyname := strings.TrimSuffix(cleanname, path.Ext(cleanname)) + finalname := fmt.Sprintf("%s_n%d_g%d_q%d.jpeg", emptyname, optionNoise, optionGenerations, optionQuality) + if err := os.WriteFile(finalname, content.Bytes(), 0660); err != nil { + fmt.Printf("Failed to write file '%s': %s\n", finalname, err.Error()) + os.Exit(1) + } +} + +// Parse Integer for CLI Arguments +func parseInteger(n string, s string, min int, max int) int { + v, err := strconv.Atoi(s) + if err != nil { + fmt.Printf("%s: Not A Number\n", n) + os.Exit(1) + } + if v < min { + fmt.Printf("%s: Value cannot be less than %d\n", n, min) + os.Exit(1) + } + if v > max { + fmt.Printf("%s: Value cannot be more than %d\n", n, max) + os.Exit(1) + } + return v +} + +// Decode Image with the appropriate decoder based on it's starting bytes +// https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files) +func decodeImage(d []byte) (image.Image, error) { + var ( + decoderImage image.Image + decoderError error + ) + switch { + case len(d) > 3 && // JPEG + d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF: + decoderImage, decoderError = jpeg.Decode(bytes.NewReader(d)) + + case len(d) > 8 && // PNG + d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 && + d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A: + decoderImage, decoderError = png.Decode(bytes.NewReader(d)) + + case len(d) > 4 && // GIF + d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38: + decoderImage, decoderError = gif.Decode(bytes.NewReader(d)) + + case len(d) > 12 && // WEBP + d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 && + d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50: + decoderImage, decoderError = webp.Decode(bytes.NewReader(d)) + + default: + return decoderImage, errors.New("unsupported file type") + } + if decoderError != nil { + return nil, decoderError + } + + return decoderImage, nil +} diff --git a/imageconvert/main.go b/imageconvert/main.go new file mode 100644 index 0000000..6624454 --- /dev/null +++ b/imageconvert/main.go @@ -0,0 +1,208 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" +) + +type QueuedItem struct { + Basename string // Filename without Extension + Filename string // Filename with Extension + Nest []string // Subdirectories +} + +const ( + OUTPUT_DIR = "convert" + OUTPUT_FLAG = 0755 +) + +var ( + featureResume bool = true + featureRecursive bool = false + featureSkipErrors bool = false + flags []string + queue []QueuedItem + workers = 1 +) + +func scan(nest []string, extensions []string) { + if len(nest) == 1 && strings.EqualFold(nest[0], OUTPUT_DIR) { + return + } + folder := path.Join(nest...) + if folder == "" { + folder = "." + } + files, err := os.ReadDir(folder) + if err != nil { + log.Fatalf("Error reading directory '%s': %s\n", folder, err) + } + for _, entry := range files { + filename := entry.Name() + + // Scan Subdirectory + if entry.IsDir() { + if featureRecursive { + scan(append(nest, filename), extensions) + } + continue + } + + // Match File Extension + for _, prefix := range extensions { + if len(filename) < len(prefix) { + continue + } + if !strings.EqualFold(filename[len(filename)-len(prefix):], prefix) { + continue + } + + // Perform Resume Check + basename := filename[:strings.LastIndex(filename, ".")] + location := fmt.Sprint(path.Join(OUTPUT_DIR, folder, basename), ".", flags[2]) + if featureResume { + if info, err := os.Stat(location); err == nil { + if info.Size() != 0 { + fmt.Printf("Skipping '%s' as it is already complete\n", filename) + continue + } + } + } + + // Add Item to Queue + queue = append(queue, QueuedItem{ + Filename: filename, + Basename: basename, + Nest: nest, + }) + break + } + } +} + +func main() { + t := time.Now() + + // Collect Arguments + for _, arg := range os.Args { + if strings.EqualFold(arg, "--skip-resume") { + log.Println("Flag: Disabling Resume Check") + featureResume = false + continue + } + if strings.EqualFold(arg, "--recursive") { + log.Println("Flag: Scanning Recursively") + featureRecursive = true + continue + } + if strings.EqualFold(arg, "--multithread") { + log.Println("Flag: Enabling Multi-threading") + workers = runtime.NumCPU() - 1 + if workers < 1 { + workers = 1 + } + continue + } + if strings.EqualFold(arg, "--skip-errors") { + log.Println("Flag: Skipping on Conversion Error") + featureSkipErrors = true + continue + } + flags = append(flags, arg) + } + if len(flags) < 3 { + fmt.Println("imageconvert") + fmt.Println(" --skip-errors - Skip on conversion error") + fmt.Println(" --skip-resume - Skip Resume Checking") + fmt.Println(" --multithread - Use Multiple Threads") + fmt.Println(" --recursive - Scan Directories Recursively") + fmt.Println(" - File Extension(s) to convert from, delimited with comma") + fmt.Println(" - File Extension to convert into") + fmt.Println(" [Arguments] - Arguments to pass onto ImageMagick") + os.Exit(0) + } + + // Scan Directory + scan([]string{}, strings.Split(flags[1], ",")) + + // Startup Workers + var consoleLock sync.Mutex + var awaitWorkers sync.WaitGroup + var itemsRemaining atomic.Int32 + itemsRemaining.Add(int32(len(queue))) + jobs := make(chan int, len(queue)) + + log.Printf("Queued Files: %d\n", len(queue)) + log.Printf("Worker Count: %d\n", workers) + + for workerID := 0; workerID < workers; workerID++ { + awaitWorkers.Add(1) + go func() { + defer awaitWorkers.Done() + for i := range jobs { + info := queue[i] + + // Generate Paths + directory := path.Join(info.Nest...) + srcPath := path.Join(directory, info.Filename) + dstPath := fmt.Sprint(path.Join(OUTPUT_DIR, directory, info.Basename), ".", flags[2]) + + if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil { + log.Fatalln("Cannot create output directory:", err) + } + + // Compile Arguments + args := make([]string, 0, len(flags)) + args = append(args, srcPath) + for i := 3; i < len(flags); i++ { + args = append(args, flags[i]) + } + args = append(args, dstPath) + proc := exec.Command("magick", args...) + + // Output Errors + if output, err := proc.CombinedOutput(); err != nil { + + consoleLock.Lock() + exitcode := -1 + if proc.ProcessState != nil { + exitcode = proc.ProcessState.ExitCode() + } + fmt.Printf("\r") + log.Printf("Processing Failed for '%s' with code: %d\n%s\n\n", + srcPath, exitcode, strings.TrimSpace(string(output))) + consoleLock.Unlock() + + if !featureSkipErrors { + os.Exit(1) + } + } + + // Output Progress + consoleLock.Lock() + fmt.Printf("\r ") + fmt.Printf("\rItems Left: %d", itemsRemaining.Add(-1)) + consoleLock.Unlock() + } + }() + } + + // Begin Processing + for i := 0; i < len(queue); i++ { + jobs <- i + } + close(jobs) + awaitWorkers.Wait() + + // Processing Complete + fmt.Printf("\n") + log.Printf("Processing Completed in %s\n", time.Since(t)) +} diff --git a/mangapub/go.mod b/mangapub/go.mod new file mode 100644 index 0000000..487b77e --- /dev/null +++ b/mangapub/go.mod @@ -0,0 +1,5 @@ +module github.com/bakonpancakz/clitools/mangapub + +go 1.24.0 + +require golang.org/x/image v0.33.0 diff --git a/mangapub/go.sum b/mangapub/go.sum new file mode 100644 index 0000000..ba0ff29 --- /dev/null +++ b/mangapub/go.sum @@ -0,0 +1,2 @@ +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= diff --git a/mangapub/main.go b/mangapub/main.go new file mode 100644 index 0000000..d798286 --- /dev/null +++ b/mangapub/main.go @@ -0,0 +1,484 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "embed" + "fmt" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io" + "log" + "math" + "os" + "path" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "text/template" + "time" + + "golang.org/x/image/draw" + "golang.org/x/image/webp" +) + +type File struct { + Name string + Images []Image +} + +type Image struct { + Name string + Data []byte + MimeType string +} + +type QueuedItem struct { + Basename string // Filename without Extension + Filename string // Filename with Extension + Nest []string // Subdirectories +} + +const ( + OUTPUT_DIR = "convert" + OUTPUT_FLAG = 0755 +) + +var ( + featureRecursive bool = false + featureExtract bool = false + featureHeight int = 800 + featureWidth int = 600 + featureQuality int = 25 + flags []string + queue []QueuedItem +) + +//go:embed templates/* +var templateFS embed.FS + +func main() { + t := time.Now() + + // Parse Arguments + for i := 1; i < len(os.Args); i++ { + segments := strings.SplitN(os.Args[i], "=", 2) + if len(segments) == 2 { + n := segments[0] + s := segments[1] + switch { + case strings.EqualFold(n, "--height"): + v := parseInteger(n, s, 128, math.MaxInt) + log.Printf("Flag: Height %d\n", v) + featureHeight = v + + case strings.EqualFold(n, "--width"): + v := parseInteger(n, s, 128, math.MaxInt) + log.Printf("Flag: Width %d\n", v) + featureWidth = v + + case strings.EqualFold(n, "--quality"): + v := parseInteger(n, s, 0, 100) + log.Printf("Flag: Quality %d\n", v) + featureQuality = v + + default: + log.Printf("%s: Unknown Argument", n) + os.Exit(1) + } + + } else { + n := segments[0] + if strings.EqualFold(n, "--recursive") { + log.Println("Flag: Scanning Recursively") + featureRecursive = true + continue + } + if strings.EqualFold(n, "--extract") { + log.Println("Flag: Extracting Images") + featureExtract = true + continue + } + flags = append(flags, segments[0]) + } + } + if len(flags) < 1 { + fmt.Println("mangapub") + fmt.Println(" --extract - Extract Images to Directory") + fmt.Println(" --recursive - Scan Directories Recursively") + fmt.Println(" --height= - Image Height (Default: 800)") + fmt.Println(" --width= - Image Width (Default: 600)") + fmt.Println(" --quality= - JPEG Quality (Default: 25, Range: 0-100)") + fmt.Println(" - Directory to Scan (Use \".\" for current directory)") + os.Exit(0) + } + + // Process Archives + scan([]string{}) + for _, info := range queue { + + // Generate Paths + directory := path.Join(info.Nest...) + srcPath := path.Join(directory, info.Filename) + dstPath := path.Join(OUTPUT_DIR, directory, info.Basename) + if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil { + log.Fatalln("Cannot create output directory:", err) + } + log.Printf("Converting: %s\n", srcPath) + + // Convert Archive + contents, err := ParseCBZ(srcPath) + if err != nil { + log.Printf("Failed to parse CBZ '%s': %s\n", srcPath, err) + continue + } + if featureExtract { + if err := CreateDirectory(contents, dstPath); err != nil { + log.Printf("Failed to create DIR '%s': %s\n", dstPath, err) + continue + } + } else { + if err := CreateEPUB(contents, dstPath); err != nil { + log.Printf("Failed to create EPUB '%s': %s\n", dstPath, err) + continue + } + } + + } + + // Processing Complete + fmt.Printf("\n") + log.Printf("Processing Completed in %s\n", time.Since(t)) +} + +// Parse Integer for CLI Arguments +func parseInteger(n string, s string, min int, max int) int { + v, err := strconv.Atoi(s) + if err != nil { + fmt.Printf("%s: Not A Number\n", n) + os.Exit(1) + } + if v < min { + fmt.Printf("%s: Value cannot be less than %d\n", n, min) + os.Exit(1) + } + if v > max { + fmt.Printf("%s: Value cannot be more than %d\n", n, max) + os.Exit(1) + } + return v +} + +// Scan directory and append eligible items to queue +func scan(nesting []string) { + if len(nesting) == 1 && strings.EqualFold(nesting[0], OUTPUT_DIR) { + return + } + + // Read Entries in Directory + directory := path.Join(nesting...) + if directory == "" { + directory = path.Clean(flags[0]) + nesting = []string{flags[0]} + } + dirEntries, err := os.ReadDir(directory) + if err != nil { + log.Fatalf("Error reading directory '%s': %s\n", directory, err) + } + + for _, entry := range dirEntries { + fileName := entry.Name() + + // Scan Subdirectory + if entry.IsDir() { + if featureRecursive { + scan(append(nesting, fileName)) + } + continue + } + + // Add Matching File Extensions to Queue + fileExt := path.Ext(fileName) + if !strings.EqualFold(fileExt, ".cbz") { + continue + } + queue = append(queue, QueuedItem{ + Filename: fileName, + Basename: strings.TrimSuffix(fileName, fileExt), + Nest: nesting, + }) + } +} + +func GenerateUUID() string { + uuid := make([]byte, 16) + _, err := rand.Read(uuid) + if err != nil { + // Fallback to a timestamp-based ID if random generation fails + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + + // Set version (4) and variant (RFC 4122) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", + uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]) +} + +func ParseCBZ(filename string) (*File, error) { + + // CBZ files are really just zip archives + reader, err := zip.OpenReader(filename) + if err != nil { + return nil, fmt.Errorf("failed to open CBZ file: %w", err) + } + defer reader.Close() + + cbzFile := &File{ + Name: filename, + Images: []Image{}, + } + + // Multithreaded image processing + var wc = make(chan int, len(reader.File)) + var wg sync.WaitGroup + var wm sync.Mutex + for c := 0; c < runtime.NumCPU(); c++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range wc { + + // Ignore Directories + file := reader.File[i] + if file.FileInfo().IsDir() { + continue + } + + // Read file contents inside archive + rc, err := file.Open() + if err != nil { + log.Printf("failed to open file in CBZ: %s\n", err) + continue + } + d, _ := io.ReadAll(rc) + rc.Close() + + // Decode Image with the appropriate decoder based on it's starting bytes + // https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files) + var decoderImage image.Image + var decoderError error + switch { + case len(d) > 3 && // JPEG + d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF: + decoderImage, decoderError = jpeg.Decode(bytes.NewReader(d)) + + case len(d) > 8 && // PNG + d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 && + d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A: + decoderImage, decoderError = png.Decode(bytes.NewReader(d)) + + case len(d) > 4 && // GIF + d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38: + decoderImage, decoderError = gif.Decode(bytes.NewReader(d)) + + case len(d) > 12 && // WEBP + d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 && + d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50: + decoderImage, decoderError = webp.Decode(bytes.NewReader(d)) + + default: // unsupported content type + continue + } + if decoderError != nil { + log.Printf("malformed image: %s\n", err) + continue + } + + // Calculate Scaled Height and Width + bounds := decoderImage.Bounds() + targetW, targetH := featureWidth, featureHeight + iw, ih := bounds.Dx(), bounds.Dy() + ratio := math.Min(float64(targetW)/float64(iw), float64(targetH)/float64(ih)) + sw, sh := int(float64(iw)*ratio), int(float64(ih)*ratio) + canvas := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + + // Resize Image (White Background) + for x := 0; x < targetW; x++ { + for y := 0; y < targetH; y++ { + canvas.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + } + } + offsetX := (targetW - sw) / 2 + offsetY := (targetH - sh) / 2 + draw.CatmullRom.Scale(canvas, image.Rect(offsetX, offsetY, offsetX+sw, offsetY+sh), + decoderImage, bounds, draw.Over, nil) + + // Encode Resized Image into JPEG + enc := bytes.Buffer{} + if err := jpeg.Encode(&enc, canvas, &jpeg.Options{Quality: featureQuality}); err != nil { + log.Printf("encoding error: %s\n", err) + continue + } + + // Append Image to List + wm.Lock() + cbzFile.Images = append(cbzFile.Images, Image{ + Name: strings.TrimSuffix(path.Base(file.Name), path.Ext(file.Name)) + ".jpeg", + Data: enc.Bytes(), + MimeType: "image/jpeg", + }) + wm.Unlock() + } + }() + } + + // Wait for processing to complete + for i := 0; i < len(reader.File); i++ { + wc <- i + } + close(wc) + wg.Wait() + + // Sort Images by Name + sort.Slice(cbzFile.Images, func(i, j int) bool { + return cbzFile.Images[i].Name < cbzFile.Images[j].Name + }) + return cbzFile, nil +} + +func CreateEPUB(input *File, filename string) error { + + // EPUB files are really just zip archives + writer, err := os.Create(filename + ".epub") + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer writer.Close() + + archive := zip.NewWriter(writer) + defer archive.Close() + + { + // Write Mime Header + mimetype, err := archive.CreateHeader(&zip.FileHeader{ + Name: "mimetype", + Method: zip.Store, + }) + if err != nil { + return fmt.Errorf("failed to create mimetype file: %w", err) + } + if _, err = mimetype.Write([]byte("application/epub+zip")); err != nil { + return fmt.Errorf("failed to write mimetype file: %w", err) + } + } + + type Item struct { + ID int + Base string + Type string + } + var ( + ContentTitle = strings.TrimSuffix(path.Base(input.Name), path.Ext(input.Name)) + ContentDate = time.Now().Format("2006-01-02") + ContentUUID = GenerateUUID() + ContentImages = make([]Item, 0, len(input.Images)) + ) + for i, image := range input.Images { + + // Create Metadata Entry + pathBase := fmt.Sprintf("page%03d", i+1) + pathItem := Item{ + ID: i + 1, + Base: pathBase, + Type: image.MimeType, + } + + // Add HTML to Archive + { + pathOutput := fmt.Sprint("OEBPS/pages/", pathBase, ".xhtml") + pathTemplate := "templates/page.xml" + tmpl, err := template.ParseFS(templateFS, pathTemplate) + if err != nil { + return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err) + } + output, err := archive.Create(pathOutput) + if err != nil { + return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err) + } + if err := tmpl.Execute(output, pathItem); err != nil { + return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err) + } + } + + // Add Image to Archive + { + pathOutput := fmt.Sprint("OEBPS/images/", pathBase, ".jpeg") + output, err := archive.Create(pathOutput) + if err != nil { + return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err) + } + if _, err = output.Write(image.Data); err != nil { + return fmt.Errorf("cannot write archive file '%s': %s", pathOutput, err) + } + } + + ContentImages = append(ContentImages, pathItem) + } + + { + // Generate Metadata with Templates + literals := map[string]any{ + "ContentTitle": ContentTitle, + "ContentDate": ContentDate, + "ContentUUID": ContentUUID, + "ContentImages": ContentImages, + } + for _, meta := range [][]string{ + {"OEBPS/content.opf", "templates/content.opf"}, + {"OEBPS/toc.ncx", "templates/toc.ncx"}, + {"META-INF/container.xml", "templates/container.xml"}, + } { + pathOutput := meta[0] + pathTemplate := meta[1] + tmpl, err := template.ParseFS(templateFS, pathTemplate) + if err != nil { + return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err) + } + output, err := archive.Create(pathOutput) + if err != nil { + return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err) + } + if err := tmpl.Execute(output, literals); err != nil { + return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err) + } + } + } + + return nil +} + +func CreateDirectory(input *File, filename string) error { + + // Create Output Directory + if err := os.MkdirAll(filename, OUTPUT_FLAG); err != nil { + return fmt.Errorf("failed to create output dir: %w", err) + } + + // Write Images + for i, image := range input.Images { + imageName := fmt.Sprintf("page%03d.jpeg", i+1) + imagePath := path.Join(filename, imageName) + if err := os.WriteFile(imagePath, image.Data, OUTPUT_FLAG); err != nil { + return fmt.Errorf("failed to write image: %w", err) + } + } + + return nil +} diff --git a/mangapub/templates/container.xml b/mangapub/templates/container.xml new file mode 100644 index 0000000..173afcc --- /dev/null +++ b/mangapub/templates/container.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mangapub/templates/content.opf b/mangapub/templates/content.opf new file mode 100644 index 0000000..092a10c --- /dev/null +++ b/mangapub/templates/content.opf @@ -0,0 +1,24 @@ + + + + {{ .ContentTitle }} + en + urn:uuid:{{ .ContentUUID }} + {{ .ContentDate }} + bakonpancakz + + + + {{ range .ContentImages }} + + {{ end }} + {{ range .ContentImages }} + + {{ end }} + + + {{ range .ContentImages }} + + {{ end }} + + \ No newline at end of file diff --git a/mangapub/templates/page.xml b/mangapub/templates/page.xml new file mode 100644 index 0000000..e6d37d9 --- /dev/null +++ b/mangapub/templates/page.xml @@ -0,0 +1,16 @@ + + + + + Page {{ .ID }} + + + +
+ Page {{ .ID }} +
+ + \ No newline at end of file diff --git a/mangapub/templates/toc.ncx b/mangapub/templates/toc.ncx new file mode 100644 index 0000000..a1852dc --- /dev/null +++ b/mangapub/templates/toc.ncx @@ -0,0 +1,23 @@ + + + + + + + + + + + {{ .ContentTitle }} + + + {{ range .ContentImages }} + + + Page {{ .ID }} + + + + {{ end }} + + \ No newline at end of file diff --git a/mediaconvert/main.go b/mediaconvert/main.go new file mode 100644 index 0000000..79d32ee --- /dev/null +++ b/mediaconvert/main.go @@ -0,0 +1,329 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os" + "os/exec" + "path" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +type QueuedItem struct { + Basename string // Filename without Extension + Filename string // Filename with Extension + Nest []string // Subdirectories +} + +var ( + OUTPUT_FLAG = os.FileMode(0666) + OUTPUT_DIR = "convert" + featureResume bool = true + featureRecursive bool = false + logLines []string + logMutex sync.Mutex + flags []string + queue []QueuedItem + itemsRemaining atomic.Int32 + awaitWorkers sync.WaitGroup + workers = 1 +) + +func defaultField(m map[string]string, key string) string { + if val, ok := m[key]; ok { + return val + } else { + return "" + } +} + +// Warning, magic numbers... really janky... + +func logUpdate(index int, message string) { + logMutex.Lock() + logLines[index+1] = fmt.Sprintf("[%02d] %s", index, message) + logMutex.Unlock() +} + +func logRenderer() { + logLines = make([]string, workers+3) + for i := 0; i < len(logLines); i++ { + fmt.Println() + } + for { + logMutex.Lock() + + // Footer + logLines[len(logLines)-1] = fmt.Sprintf("\rItems Left: %d\n", itemsRemaining.Load()) + + // Log Lines + fmt.Printf("\033[%dA", len(logLines)+1) + for _, line := range logLines { + fmt.Printf("\033[2K\r%-80s\n", line) + } + fmt.Printf("\r") + + logMutex.Unlock() + time.Sleep(100 * time.Millisecond) + } +} + +func scan(nest []string, extensions []string) { + if len(nest) == 1 && strings.EqualFold(nest[0], OUTPUT_DIR) { + return + } + folder := path.Join(nest...) + if folder == "" { + folder = "." + } + files, err := os.ReadDir(folder) + if err != nil { + log.Fatalf("Error reading directory '%s': %s\n", folder, err) + } + for _, entry := range files { + filename := entry.Name() + + // Scan Subdirectory + if entry.IsDir() { + if featureRecursive { + scan(append(nest, filename), extensions) + } + continue + } + + // Match File Extension + for _, prefix := range extensions { + if len(filename) < len(prefix) { + continue + } + if !strings.EqualFold(filename[len(filename)-len(prefix):], prefix) { + continue + } + + // Perform Resume Check + basename := filename[:strings.LastIndex(filename, ".")] + location := fmt.Sprint(path.Join(OUTPUT_DIR, folder, basename), ".", flags[2]) + if featureResume { + if info, err := os.Stat(location); err == nil { + if info.Size() != 0 { + fmt.Printf("Skipping '%s' as it is already complete\n", filename) + continue + } + } + } + + // Add Item to Queue + queue = append(queue, QueuedItem{ + Filename: filename, + Basename: basename, + Nest: nest, + }) + break + } + } +} + +func main() { + t := time.Now() + + // Enable ANSI escape codes on Windows 10+ + // I don't remember where I copied this from, sorry... (>_>) + if runtime.GOOS == "windows" { + stdout := os.Stdout.Fd() + var mode uint32 + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode") + proc.Call(stdout, uintptr(unsafe.Pointer(&mode))) + mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING + proc = syscall.NewLazyDLL("kernel32.dll").NewProc("SetConsoleMode") + proc.Call(stdout, uintptr(mode)) + } + + // Collect Arguments + for _, arg := range os.Args { + before, after, _ := strings.Cut(arg, "=") + if strings.EqualFold(before, "--skip-resume") { + log.Println("Flag: Disabling Resume Check") + featureResume = false + continue + } + if strings.EqualFold(before, "--recursive") { + log.Println("Flag: Scanning Recursively") + featureRecursive = true + continue + } + if strings.EqualFold(before, "--multithread") { + log.Println("Flag: Enabling Multi-threading") + workers = runtime.NumCPU() + continue + } + if strings.EqualFold(before, "--output") { + info, err := os.Stat(after) + if err == nil { + if !info.IsDir() { + err = fmt.Errorf("Not a directory") + } + } + if err != nil { + log.Fatalln("Invalid Output Directory:", err) + return + } + log.Println("Flag: Setting Output Directory:", after) + OUTPUT_DIR = after + continue + } + flags = append(flags, arg) + } + if len(flags) < 3 { + fmt.Println("mediaconvert") + fmt.Println(" --skip-resume - Skip Resume Checking") + fmt.Println(" --multithread - Use Multiple Threads") + fmt.Println(" --recursive - Scan Directories Recursively") + fmt.Println(" --output={...} - Set Output Directory") + fmt.Println(" - File Extension(s) to convert from, delimited with comma") + fmt.Println(" - File Extension to convert into") + fmt.Println(" [Arguments] - Arguments to pass onto FFMPEG") + fmt.Println("Templates:") + fmt.Println(" {filename} - Full Filename (e.g. myfile.txt)") + fmt.Println(" {basename} - Base Filename (e.g. myfile") + fmt.Println(" {directory} - Source Directory (e.g. /path/to/file)") + os.Exit(0) + } + + // Scan Directory + scan([]string{}, strings.Split(flags[1], ",")) + + // Startup Workers + log.Printf("Queued Files: %d\n", len(queue)) + log.Printf("Worker Count: %d\n", workers) + jobs := make(chan int, len(queue)) + itemsRemaining.Add(int32(len(queue))) + + if len(queue) > 0 { + go logRenderer() + } + + for i := 0; i < workers; i++ { + awaitWorkers.Add(1) + go func(workerID int) { + defer awaitWorkers.Done() + for i := range jobs { + info := queue[i] + s := time.Now() + + // Generate Paths + directory := path.Join(info.Nest...) + srcPath := path.Join(directory, info.Filename) + dstPath := fmt.Sprint(path.Join(OUTPUT_DIR, directory, info.Basename), ".", flags[2]) + + if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil { + log.Fatalln("Cannot create output directory:", err) + } + + // Compile Arguments + args := []string{"-hide_banner", "-y", "-progress", "-", "-i", srcPath} + for i := 3; i < len(flags); i++ { + str := flags[i] + str = strings.ReplaceAll(str, "{basename}", info.Basename) + str = strings.ReplaceAll(str, "{filename}", info.Filename) + str = strings.ReplaceAll(str, "{directory}", path.Join(info.Nest...)) + args = append(args, str) + } + args = append(args, dstPath) + proc := exec.Command("ffmpeg", args...) + + // Collect Error Output + errors := bytes.Buffer{} + stderr, err := proc.StderrPipe() + if err != nil { + log.Fatalf("Failed to open Error Output: %s\n", err) + } + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + errors.Write(scanner.Bytes()) + errors.WriteRune('\n') + } + }() + + // Collect Progress Output + output, err := proc.StdoutPipe() + if err != nil { + log.Fatalf("Failed to open Standard Output: %s\n", err) + } + go func() { + for { + buffer := make([]byte, 256) + r, err := output.Read(buffer) + if err != nil { + break + } + progress := map[string]string{} + metadata := strings.Split(string(buffer[:r]), "\n") + for _, line := range metadata { + values := strings.Split(line, "=") + if len(values) == 2 { + key := strings.TrimSpace(values[0]) + val := strings.TrimSpace(values[1]) + progress[key] = val + } + } + switch defaultField(progress, "progress") { + case "continue": + // Clear Output and Display Progress + sizeTotal := defaultField(progress, "total_size") + sizeFloat, _ := strconv.ParseFloat(sizeTotal, 64) + sizeValue := strconv.FormatFloat(sizeFloat/1024/1024, 'f', 2, 64) + logUpdate(workerID, fmt.Sprintf( + "Time: %s, Bitrate: %s, FPS: %s/%s/%s, Size: %sMB (%s)", + defaultField(progress, "out_time"), + defaultField(progress, "bitrate"), + defaultField(progress, "fps"), + defaultField(progress, "drop_frames"), + defaultField(progress, "dup_frames"), + sizeValue, + defaultField(progress, "speed"), + )) + case "end": + // Clear Output and Display Completion Time + logUpdate(workerID, fmt.Sprintf( + "Processing Completed in %s", + time.Since(s), + )) + } + } + }() + + // Start Processing + if err := proc.Run(); err != nil { + exitcode := -1 + if proc.ProcessState != nil { + exitcode = proc.ProcessState.ExitCode() + } + log.Printf("Processing Failed for '%s' with code: %d\n%s\n\n", + srcPath, exitcode, errors.String()) + os.Exit(1) + } + itemsRemaining.Add(-1) + } + }(i) + } + + // Begin Processing + for i := 0; i < len(queue); i++ { + jobs <- i + } + close(jobs) + awaitWorkers.Wait() + + // Processing Complete + log.Printf("Processing Completed in %s\n", time.Since(t)) +}