Initial Release
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.vestige
|
||||||
|
dependencies
|
||||||
|
bin
|
||||||
|
lib
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/bakonpancakz/clitools/crunchy
|
||||||
|
|
||||||
|
go 1.25.2
|
||||||
|
|
||||||
|
require golang.org/x/image v0.33.0
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/bakonpancakz/clitools/mangapub
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require golang.org/x/image v0.33.0
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user