Initial Release

This commit is contained in:
2026-05-23 17:16:14 -07:00
commit 1c6dfc880d
16 changed files with 1415 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.vestige
dependencies
bin
lib
+21
View File
@@ -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.
+93
View File
@@ -0,0 +1,93 @@
<!-- omit from toc -->
# `>_` 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)
<br>
## `(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
<From> - File Extension(s) to convert from, delimited with comma
<To> - File Extension to convert into
[Arguments] - Arguments to pass onto ImageMagick
```
<br>
## `(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
<From> - File Extension(s) to convert from, delimited with comma
<To> - 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)
```
<br>
## `(crunchy)` Turn Image into a Crunchy JPEG
Applies random noise and rounding errors to the colorspace to make an image look **"crunchy"**
```
crunchy
--noise=<value> - Noise Level (Default: 25, Range: 0-100)
--quality=<value> - JPEG Quality (Default: 0, Range: 0-100)
--generations=<count> - Iterations (Default: 5)
<Filename> - Input Filename
```
<p align="center">
<img src="crunchy/example.png">
<p align="center">Another satisfied customer!</p>
</p>
<br>
## `(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=<value> - Image Height (Default: 800)
--width=<value> - Image Width (Default: 600)
--quality=<value> - JPEG Quality (Default: 25, Range: 0-100)
```
> Highly modified version of this repo: https://github.com/DimazzzZ/cbz2epub
<br>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+5
View File
@@ -0,0 +1,5 @@
module github.com/bakonpancakz/clitools/crunchy
go 1.25.2
require golang.org/x/image v0.33.0
+2
View File
@@ -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=
+193
View File
@@ -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=<value> - Noise Level (Default: 25, Range: 0-100)")
fmt.Println(" --quality=<value> - JPEG Quality (Default: 0, Range: 0-100)")
fmt.Println(" --generations=<count> - Iterations (Default: 5)")
fmt.Println(" <Filename> - 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
}
+208
View File
@@ -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(" <From> - File Extension(s) to convert from, delimited with comma")
fmt.Println(" <To> - 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))
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/bakonpancakz/clitools/mangapub
go 1.24.0
require golang.org/x/image v0.33.0
+2
View File
@@ -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=
+484
View File
@@ -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=<value> - Image Height (Default: 800)")
fmt.Println(" --width=<value> - Image Width (Default: 600)")
fmt.Println(" --quality=<value> - JPEG Quality (Default: 25, Range: 0-100)")
fmt.Println(" <directory> - 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
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookID" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>{{ .ContentTitle }}</dc:title>
<dc:language>en</dc:language>
<dc:identifier id="BookID">urn:uuid:{{ .ContentUUID }}</dc:identifier>
<dc:date>{{ .ContentDate }}</dc:date>
<dc:creator>bakonpancakz</dc:creator>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
{{ range .ContentImages }}
<item id="{{ .ID }}" href="images/{{ .Base }}.jpeg" media-type="{{ .Type }}"/>
{{ end }}
{{ range .ContentImages }}
<item id="page{{ .ID }}" href="pages/{{ .Base }}.xhtml" media-type="application/xhtml+xml"/>
{{ end }}
</manifest>
<spine toc="ncx">
{{ range .ContentImages }}
<itemref idref="page{{ .ID }}"/>
{{ end }}
</spine>
</package>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Page {{ .ID }}</title>
<style type="text/css">
img { max-width: 100%; max-height: 100%; }
body { margin: 0; padding: 0; text-align: center; }
</style>
</head>
<body>
<div>
<img src="../images/{{ .Base }}.jpeg" alt="Page {{ .ID }}" />
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="urn:uuid:{{ .ContentUUID }}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text>{{ .ContentTitle }}</text>
</docTitle>
<navMap>
{{ range .ContentImages }}
<navPoint id="navpoint-{{ .ID }}" playOrder="{{ .ID }}">
<navLabel>
<text>Page {{ .ID }}</text>
</navLabel>
<content src="pages/page{{ .ID }}.xhtml"/>
</navPoint>
{{ end }}
</navMap>
</ncx>
+329
View File
@@ -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(" <From> - File Extension(s) to convert from, delimited with comma")
fmt.Println(" <To> - 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))
}