Files
CBZOptimizer/pkg/converter/webp/webp_converter.go
T
164dc577b9 refactor!: disk-first zero-copy architecture with file-to-file cwebp conversion
BREAKING CHANGE: Complete refactoring of the conversion pipeline.

- Replace in-memory Page/PageContainer with disk-based PageFile struct
- Extract archives to temp directory instead of loading into memory
- Use cwebp InputFile/OutputFile for zero-copy file-to-file conversion
- Use cwebp -crop for splitting (no Go-side image decode needed)
- Check if already converted before extracting (fast pre-check)
- Remove oliamb/cutter dependency (replaced by cwebp -crop)
- Remove page_container.go (no longer needed)
- Add comprehensive tests with improved coverage

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-04 22:18:22 +00:00

348 lines
9.3 KiB
Go

package webp
import (
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
"strings"
"sort"
"sync"
"sync/atomic"
"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/rs/zerolog/log"
_ "golang.org/x/image/webp"
)
const webpMaxHeight = 16383
type Converter struct {
maxHeight int
cropHeight int
isPrepared bool
// pageWorkerGuard limits concurrent cwebp processes across all chapters.
pageWorkerGuard chan struct{}
}
func (converter *Converter) Format() constant.ConversionFormat {
return constant.WebP
}
func New() *Converter {
return &Converter{
maxHeight: 4000,
cropHeight: 2000,
isPrepared: false,
pageWorkerGuard: make(chan struct{}, runtime.NumCPU()),
}
}
func (converter *Converter) PrepareConverter() error {
if converter.isPrepared {
return nil
}
err := PrepareEncoder()
if err != nil {
return err
}
converter.isPrepared = true
return nil
}
// ConvertChapter converts all pages in a chapter using file-to-file cwebp operations.
// In the happy path, no image data is loaded into Go memory.
// Splitting is attempted only if direct conversion fails due to dimension limits.
func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
log.Debug().
Str("chapter", chapter.FilePath).
Int("pages", len(chapter.Pages)).
Uint8("quality", quality).
Bool("split", split).
Msg("Starting file-to-file chapter conversion")
err := converter.PrepareConverter()
if err != nil {
return nil, err
}
// Create output directory for converted files
outputDir := filepath.Join(chapter.TempDir, "output")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
// Check for early context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
guard := converter.pageWorkerGuard
var totalPages atomic.Uint32
totalPages.Store(uint32(len(chapter.Pages)))
type pageResult struct {
pages []*manga.PageFile
err error
}
results := make([]pageResult, len(chapter.Pages))
var wg sync.WaitGroup
var convertedCount atomic.Uint32
for i, page := range chapter.Pages {
wg.Add(1)
go func(idx int, p *manga.PageFile) {
defer wg.Done()
// Check context before acquiring worker slot
select {
case <-ctx.Done():
results[idx] = pageResult{err: ctx.Err()}
return
case guard <- struct{}{}:
}
defer func() { <-guard }()
// Check context after acquiring worker slot
select {
case <-ctx.Done():
results[idx] = pageResult{err: ctx.Err()}
return
default:
}
pages, err := converter.convertPageFile(ctx, p, outputDir, quality, split)
results[idx] = pageResult{pages: pages, err: err}
current := convertedCount.Add(1)
total := totalPages.Load()
progress(fmt.Sprintf("Converted %d/%d pages to %s format", current, total, converter.Format()), current, total)
}(i, page)
}
// Wait for completion or context cancellation
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
// Wait for in-flight goroutines to finish
<-done
return nil, ctx.Err()
}
// Collect results
var convertedPages []*manga.PageFile
var errList []error
for _, result := range results {
if result.err != nil {
if errors.Is(result.err, context.DeadlineExceeded) || errors.Is(result.err, context.Canceled) {
return nil, result.err
}
errList = append(errList, result.err)
}
if result.pages != nil {
convertedPages = append(convertedPages, result.pages...)
}
}
if len(convertedPages) == 0 {
if len(errList) > 0 {
return nil, errors.Join(errList...)
}
return nil, fmt.Errorf("no pages were converted")
}
// Sort pages by index and split part
sort.Slice(convertedPages, func(i, j int) bool {
a, b := convertedPages[i], convertedPages[j]
if a.Index == b.Index {
return a.SplitPartIndex < b.SplitPartIndex
}
return a.Index < b.Index
})
chapter.Pages = convertedPages
var aggregatedError error
if len(errList) > 0 {
aggregatedError = errors.Join(errList...)
}
log.Debug().
Str("chapter", chapter.FilePath).
Int("converted_pages", len(convertedPages)).
Msg("Chapter conversion completed")
return chapter, aggregatedError
}
// convertPageFile converts a single page file to WebP format.
// Returns the converted page(s) — multiple if splitting was needed.
func (converter *Converter) convertPageFile(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, split bool) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Str("input", page.FilePath).
Msg("Converting page file")
// If the page is already WebP, just return it as-is
if strings.ToLower(page.Extension) == ".webp" {
log.Debug().Uint16("page_index", page.Index).Msg("Page already WebP, skipping")
return []*manga.PageFile{page}, nil
}
// Try direct file-to-file conversion first (happy path — no memory allocation)
outputPath := filepath.Join(outputDir, fmt.Sprintf("%04d.webp", page.Index))
err := EncodeFile(page.FilePath, outputPath, uint(quality))
if err == nil {
// Success! No image decoding needed.
return []*manga.PageFile{{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
}}, nil
}
// Direct conversion failed. Check if it's a dimension issue.
log.Debug().
Uint16("page_index", page.Index).
Err(err).
Msg("Direct conversion failed, checking dimensions")
// Read just the image header to get dimensions (no full decode)
width, height, decodeErr := getImageDimensions(page.FilePath)
if decodeErr != nil {
// Can't even read the image header — keep the original file
log.Info().
Uint16("page_index", page.Index).
Err(decodeErr).
Msg("Cannot decode image, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: failed to decode image (%s)", page.Index, decodeErr.Error()))
}
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Msg("Image dimensions read")
// If height exceeds WebP max and split is not enabled, keep original
if height >= webpMaxHeight && !split {
log.Info().
Uint16("page_index", page.Index).
Int("height", height).
Msg("Page too tall for WebP, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
}
// If height exceeds our split threshold and split is enabled, use cwebp -crop
if height >= converter.maxHeight && split {
return converter.splitAndConvert(ctx, page, outputDir, quality, width, height)
}
// Height is within limits but conversion still failed for another reason.
// Keep the original file.
log.Warn().
Uint16("page_index", page.Index).
Err(err).
Msg("Conversion failed for non-dimension reason, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: conversion failed (%s)", page.Index, err.Error()))
}
// splitAndConvert splits a tall image into multiple parts using cwebp -crop
// and converts each part. No Go-side image decode is needed.
func (converter *Converter) splitAndConvert(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, width, height int) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Int("crop_height", converter.cropHeight).
Msg("Splitting and converting page using cwebp -crop")
numParts := height / converter.cropHeight
if height%converter.cropHeight != 0 {
numParts++
}
var pages []*manga.PageFile
for i := 0; i < numParts; i++ {
select {
case <-ctx.Done():
return pages, ctx.Err()
default:
}
yOffset := i * converter.cropHeight
partHeight := converter.cropHeight
if i == numParts-1 {
partHeight = height - yOffset
}
outputPath := filepath.Join(outputDir, fmt.Sprintf("%04d-%02d.webp", page.Index, i))
err := EncodeFileWithCrop(page.FilePath, outputPath, uint(quality), 0, yOffset, width, partHeight)
if err != nil {
log.Error().
Uint16("page_index", page.Index).
Int("part", i).
Err(err).
Msg("Failed to convert split part")
return nil, fmt.Errorf("failed to convert split part %d of page %d: %w", i, page.Index, err)
}
pages = append(pages, &manga.PageFile{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
IsSplitted: true,
SplitPartIndex: uint16(i),
})
}
log.Debug().
Uint16("page_index", page.Index).
Int("parts", len(pages)).
Msg("Split conversion completed")
return pages, nil
}
// getImageDimensions reads only the image header to determine dimensions.
// This is much cheaper than a full image.Decode — only a few bytes are read.
func getImageDimensions(filePath string) (width, height int, err error) {
f, err := os.Open(filePath)
if err != nil {
return 0, 0, err
}
defer func() { _ = f.Close() }()
config, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return config.Width, config.Height, nil
}