mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2025-10-14 04:28:51 +02:00
refactor: update import paths to use internal package
This commit is contained in:
42
pkg/converter/constant/format.go
Normal file
42
pkg/converter/constant/format.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package constant
|
||||
|
||||
import "github.com/thediveo/enumflag/v2"
|
||||
|
||||
type ConversionFormat enumflag.Flag
|
||||
|
||||
const (
|
||||
WebP ConversionFormat = iota
|
||||
)
|
||||
|
||||
var CommandValue = map[ConversionFormat][]string{
|
||||
WebP: {"webp"},
|
||||
}
|
||||
|
||||
var HelpText = enumflag.Help[ConversionFormat]{
|
||||
WebP: "WebP Image Format",
|
||||
}
|
||||
|
||||
var DefaultConversion = WebP
|
||||
|
||||
func (c ConversionFormat) String() string {
|
||||
return CommandValue[c][0]
|
||||
}
|
||||
|
||||
func ListAll() []string {
|
||||
var formats []string
|
||||
for _, names := range CommandValue {
|
||||
formats = append(formats, names[0])
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
func FindConversionFormat(format string) ConversionFormat {
|
||||
for convFormat, names := range CommandValue {
|
||||
for _, name := range names {
|
||||
if name == format {
|
||||
return convFormat
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultConversion
|
||||
}
|
43
pkg/converter/converter.go
Normal file
43
pkg/converter/converter.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/webp"
|
||||
"github.com/samber/lo"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Converter interface {
|
||||
// Format of the converter
|
||||
Format() (format constant.ConversionFormat)
|
||||
// ConvertChapter converts a manga chapter to the specified format.
|
||||
//
|
||||
// Returns partial success where some pages are converted and some are not.
|
||||
ConvertChapter(chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error)
|
||||
PrepareConverter() error
|
||||
}
|
||||
|
||||
var converters = map[constant.ConversionFormat]Converter{
|
||||
constant.WebP: webp.New(),
|
||||
}
|
||||
|
||||
// Available returns a list of available converters.
|
||||
func Available() []constant.ConversionFormat {
|
||||
return lo.Keys(converters)
|
||||
}
|
||||
|
||||
// Get returns a packer by name.
|
||||
// If the packer is not available, an error is returned.
|
||||
var Get = getConverter
|
||||
|
||||
func getConverter(name constant.ConversionFormat) (Converter, error) {
|
||||
if converter, ok := converters[name]; ok {
|
||||
return converter, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unkown converter \"%s\", available options are %s", name, strings.Join(lo.Map(Available(), func(item constant.ConversionFormat, index int) string {
|
||||
return item.String()
|
||||
}), ", "))
|
||||
}
|
234
pkg/converter/converter_test.go
Normal file
234
pkg/converter/converter_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
"github.com/belphemur/CBZOptimizer/v2/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
"golang.org/x/exp/slices"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertChapter(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
genTestChapter func(path string) (*manga.Chapter, error)
|
||||
split bool
|
||||
expectFailure []constant.ConversionFormat
|
||||
expectPartialSuccess []constant.ConversionFormat
|
||||
}{
|
||||
{
|
||||
name: "All split pages",
|
||||
genTestChapter: genHugePage,
|
||||
split: true,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
},
|
||||
{
|
||||
name: "Big Pages, no split",
|
||||
genTestChapter: genHugePage,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{constant.WebP},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
},
|
||||
{
|
||||
name: "No split pages",
|
||||
genTestChapter: genSmallPages,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
},
|
||||
{
|
||||
name: "Mix of split and no split pages",
|
||||
genTestChapter: genMixSmallBig,
|
||||
split: true,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
},
|
||||
{
|
||||
name: "Mix of Huge and small page",
|
||||
genTestChapter: genMixSmallHuge,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{constant.WebP},
|
||||
},
|
||||
}
|
||||
// Load test genTestChapter from testdata
|
||||
temp, err := os.CreateTemp("", "test_chapter_*.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temporary file: %v", err)
|
||||
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.Remove, temp.Name(), "failed to remove temporary file")
|
||||
for _, converter := range Available() {
|
||||
converter, err := Get(converter)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get converter: %v", err)
|
||||
}
|
||||
t.Run(converter.Format().String(), func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
chapter, err := tc.genTestChapter(temp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test genTestChapter: %v", err)
|
||||
}
|
||||
|
||||
quality := uint8(80)
|
||||
|
||||
progress := func(msg string, current uint32, total uint32) {
|
||||
t.Log(msg)
|
||||
}
|
||||
|
||||
convertedChapter, err := converter.ConvertChapter(chapter, quality, tc.split, progress)
|
||||
if err != nil {
|
||||
if convertedChapter != nil && slices.Contains(tc.expectPartialSuccess, converter.Format()) {
|
||||
t.Logf("Partial success to convert genTestChapter: %v", err)
|
||||
return
|
||||
}
|
||||
if slices.Contains(tc.expectFailure, converter.Format()) {
|
||||
t.Logf("Expected failure to convert genTestChapter: %v", err)
|
||||
return
|
||||
}
|
||||
t.Fatalf("failed to convert genTestChapter: %v", err)
|
||||
} else if slices.Contains(tc.expectFailure, converter.Format()) {
|
||||
t.Fatalf("expected failure to convert genTestChapter didn't happen")
|
||||
}
|
||||
|
||||
if len(convertedChapter.Pages) == 0 {
|
||||
t.Fatalf("no pages were converted")
|
||||
}
|
||||
|
||||
if len(convertedChapter.Pages) != len(chapter.Pages) {
|
||||
t.Fatalf("converted chapter has different number of pages")
|
||||
}
|
||||
|
||||
for _, page := range convertedChapter.Pages {
|
||||
if page.Extension != ".webp" {
|
||||
t.Errorf("page %d was not converted to webp format", page.Index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func genHugePage(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
var pages []*manga.Page
|
||||
for i := 0; i < 1; i++ { // Assuming there are 5 pages for the test
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 17000))
|
||||
buf := new(bytes.Buffer)
|
||||
err := jpeg.Encode(buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func genSmallPages(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
var pages []*manga.Page
|
||||
for i := 0; i < 5; i++ { // Assuming there are 5 pages for the test
|
||||
img := image.NewRGBA(image.Rect(0, 0, 300, 1000))
|
||||
buf := new(bytes.Buffer)
|
||||
err := jpeg.Encode(buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func genMixSmallBig(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
var pages []*manga.Page
|
||||
for i := 0; i < 5; i++ { // Assuming there are 5 pages for the test
|
||||
img := image.NewRGBA(image.Rect(0, 0, 300, 1000*(i+1)))
|
||||
buf := new(bytes.Buffer)
|
||||
err := jpeg.Encode(buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func genMixSmallHuge(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
var pages []*manga.Page
|
||||
for i := 0; i < 10; i++ { // Assuming there are 5 pages for the test
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 2000*(i+1)))
|
||||
buf := new(bytes.Buffer)
|
||||
err := jpeg.Encode(buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
13
pkg/converter/errors/converter_errors.go
Normal file
13
pkg/converter/errors/converter_errors.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package errors
|
||||
|
||||
type PageIgnoredError struct {
|
||||
s string
|
||||
}
|
||||
|
||||
func (e *PageIgnoredError) Error() string {
|
||||
return e.s
|
||||
}
|
||||
|
||||
func NewPageIgnored(text string) error {
|
||||
return &PageIgnoredError{text}
|
||||
}
|
247
pkg/converter/webp/webp_converter.go
Normal file
247
pkg/converter/webp/webp_converter.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
converterrors "github.com/belphemur/CBZOptimizer/v2/pkg/converter/errors"
|
||||
"github.com/oliamb/cutter"
|
||||
"golang.org/x/exp/slices"
|
||||
_ "golang.org/x/image/webp"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const webpMaxHeight = 16383
|
||||
|
||||
type Converter struct {
|
||||
maxHeight int
|
||||
cropHeight int
|
||||
isPrepared bool
|
||||
}
|
||||
|
||||
func (converter *Converter) Format() (format constant.ConversionFormat) {
|
||||
return constant.WebP
|
||||
}
|
||||
|
||||
func New() *Converter {
|
||||
return &Converter{
|
||||
//maxHeight: 16383 / 2,
|
||||
maxHeight: 4000,
|
||||
cropHeight: 2000,
|
||||
isPrepared: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (converter *Converter) PrepareConverter() error {
|
||||
if converter.isPrepared {
|
||||
return nil
|
||||
}
|
||||
err := PrepareEncoder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
converter.isPrepared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (converter *Converter) ConvertChapter(chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
|
||||
err := converter.PrepareConverter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wgConvertedPages sync.WaitGroup
|
||||
maxGoroutines := runtime.NumCPU()
|
||||
|
||||
pagesChan := make(chan *manga.PageContainer, maxGoroutines)
|
||||
errChan := make(chan error, maxGoroutines)
|
||||
|
||||
var wgPages sync.WaitGroup
|
||||
wgPages.Add(len(chapter.Pages))
|
||||
|
||||
guard := make(chan struct{}, maxGoroutines)
|
||||
pagesMutex := sync.Mutex{}
|
||||
var pages []*manga.Page
|
||||
var totalPages = uint32(len(chapter.Pages))
|
||||
|
||||
go func() {
|
||||
for page := range pagesChan {
|
||||
guard <- struct{}{} // would block if guard channel is already filled
|
||||
go func(pageToConvert *manga.PageContainer) {
|
||||
defer wgConvertedPages.Done()
|
||||
convertedPage, err := converter.convertPage(pageToConvert, quality)
|
||||
if err != nil {
|
||||
if convertedPage == nil {
|
||||
errChan <- err
|
||||
<-guard
|
||||
return
|
||||
}
|
||||
buffer := new(bytes.Buffer)
|
||||
err := png.Encode(buffer, convertedPage.Image)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
<-guard
|
||||
return
|
||||
}
|
||||
convertedPage.Page.Contents = buffer
|
||||
convertedPage.Page.Extension = ".png"
|
||||
convertedPage.Page.Size = uint64(buffer.Len())
|
||||
}
|
||||
pagesMutex.Lock()
|
||||
pages = append(pages, convertedPage.Page)
|
||||
progress(fmt.Sprintf("Converted %d/%d pages to %s format", len(pages), totalPages, converter.Format()), uint32(len(pages)), totalPages)
|
||||
pagesMutex.Unlock()
|
||||
<-guard
|
||||
}(page)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, page := range chapter.Pages {
|
||||
go func(page *manga.Page) {
|
||||
defer wgPages.Done()
|
||||
|
||||
splitNeeded, img, format, err := converter.checkPageNeedsSplit(page, split)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
// Partial error in this case, we want the page, but not converting it
|
||||
if img != nil {
|
||||
wgConvertedPages.Add(1)
|
||||
pagesChan <- manga.NewContainer(page, img, format, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !splitNeeded {
|
||||
wgConvertedPages.Add(1)
|
||||
pagesChan <- manga.NewContainer(page, img, format, true)
|
||||
return
|
||||
}
|
||||
images, err := converter.cropImage(img)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
atomic.AddUint32(&totalPages, uint32(len(images)-1))
|
||||
for i, img := range images {
|
||||
page := &manga.Page{Index: page.Index, IsSplitted: true, SplitPartIndex: uint16(i)}
|
||||
wgConvertedPages.Add(1)
|
||||
pagesChan <- manga.NewContainer(page, img, "N/A", true)
|
||||
}
|
||||
}(page)
|
||||
}
|
||||
|
||||
wgPages.Wait()
|
||||
wgConvertedPages.Wait()
|
||||
close(pagesChan)
|
||||
close(errChan)
|
||||
|
||||
var errList []error
|
||||
for err := range errChan {
|
||||
errList = append(errList, err)
|
||||
}
|
||||
|
||||
var aggregatedError error = nil
|
||||
if len(errList) > 0 {
|
||||
aggregatedError = errors.Join(errList...)
|
||||
}
|
||||
|
||||
slices.SortFunc(pages, func(a, b *manga.Page) int {
|
||||
if a.Index == b.Index {
|
||||
return int(b.SplitPartIndex - a.SplitPartIndex)
|
||||
}
|
||||
return int(b.Index - a.Index)
|
||||
})
|
||||
chapter.Pages = pages
|
||||
|
||||
runtime.GC()
|
||||
|
||||
return chapter, aggregatedError
|
||||
}
|
||||
|
||||
func (converter *Converter) cropImage(img image.Image) ([]image.Image, error) {
|
||||
bounds := img.Bounds()
|
||||
height := bounds.Dy()
|
||||
|
||||
numParts := height / converter.cropHeight
|
||||
if height%converter.cropHeight != 0 {
|
||||
numParts++
|
||||
}
|
||||
|
||||
parts := make([]image.Image, numParts)
|
||||
|
||||
for i := 0; i < numParts; i++ {
|
||||
partHeight := converter.cropHeight
|
||||
if i == numParts-1 {
|
||||
partHeight = height - i*converter.cropHeight
|
||||
}
|
||||
|
||||
part, err := cutter.Crop(img, cutter.Config{
|
||||
Width: bounds.Dx(),
|
||||
Height: partHeight,
|
||||
Anchor: image.Point{Y: i * converter.cropHeight},
|
||||
Mode: cutter.TopLeft,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error cropping part %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
parts[i] = part
|
||||
}
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func (converter *Converter) checkPageNeedsSplit(page *manga.Page, splitRequested bool) (bool, image.Image, string, error) {
|
||||
reader := io.Reader(bytes.NewBuffer(page.Contents.Bytes()))
|
||||
img, format, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return false, nil, format, err
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
height := bounds.Dy()
|
||||
|
||||
if height >= webpMaxHeight && !splitRequested {
|
||||
return false, img, format, converterrors.NewPageIgnored(fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
|
||||
}
|
||||
return height >= converter.maxHeight && splitRequested, img, format, nil
|
||||
}
|
||||
|
||||
func (converter *Converter) convertPage(container *manga.PageContainer, quality uint8) (*manga.PageContainer, error) {
|
||||
if container.Format == "webp" {
|
||||
return container, nil
|
||||
}
|
||||
if !container.IsToBeConverted {
|
||||
return container, nil
|
||||
}
|
||||
converted, err := converter.convert(container.Image, uint(quality))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
container.Page.Contents = converted
|
||||
container.Page.Extension = ".webp"
|
||||
container.Page.Size = uint64(converted.Len())
|
||||
return container, nil
|
||||
}
|
||||
|
||||
// convert converts an image to the WebP format. It decodes the image from the input buffer,
|
||||
// encodes it as a WebP file using the webp.Encode() function, and returns the resulting WebP
|
||||
// file as a bytes.Buffer.
|
||||
func (converter *Converter) convert(image image.Image, quality uint) (*bytes.Buffer, error) {
|
||||
var buf bytes.Buffer
|
||||
err := Encode(&buf, image, quality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
19
pkg/converter/webp/webp_provider.go
Normal file
19
pkg/converter/webp/webp_provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"github.com/kolesa-team/go-webp/encoder"
|
||||
"github.com/kolesa-team/go-webp/webp"
|
||||
"image"
|
||||
"io"
|
||||
)
|
||||
|
||||
func PrepareEncoder() error {
|
||||
return nil
|
||||
}
|
||||
func Encode(w io.Writer, m image.Image, quality uint) error {
|
||||
options, err := encoder.NewLossyEncoderOptions(encoder.PresetDefault, float32(quality))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return webp.Encode(w, m, options)
|
||||
}
|
Reference in New Issue
Block a user