diff --git a/README.md b/README.md index 172cd50..b805ecb 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ For each CBZ, the tool: 3. Detects double-page artwork using its width-to-height ratio. 4. Splits each detected spread into its original left and right halves and places them in one native facing-page KFX section. 5. Resizes artwork without changing its aspect ratio, up to 1986×2648 pixels per physical page. -6. Uses the artwork dimensions as the KFX logical page dimensions. This prevents Kindle's four Virtual Panels from magnifying black aspect-ratio margins. -7. Adds a black fixed-layout page background, comic flags, horizontal Virtual Panels, reading direction, cover, and native spread metadata. -8. Builds a Kindle Publishing Format (`.kpf`) package in Node.js. -9. Uses Calibre's KFX Output plugin to compile the KPF into a sideloadable `.kfx` file. -10. Writes supported ComicInfo fields into the final KFX metadata with Calibre's `ebook-meta`. +6. Encodes artwork as 4:4:4 JPEGs using MozJPEG by default, with JPEGli and the former fast encoder available as alternatives. +7. Uses the artwork dimensions as the KFX logical page dimensions. This prevents Kindle's four Virtual Panels from magnifying black aspect-ratio margins. +8. Adds a black fixed-layout page background, comic flags, horizontal Virtual Panels, reading direction, cover, and native spread metadata. +9. Builds a Kindle Publishing Format (`.kpf`) package in Node.js. +10. Uses Calibre's KFX Output plugin to compile the KPF into a sideloadable `.kfx` file. +11. Writes supported ComicInfo fields into the final KFX metadata with Calibre's `ebook-meta`. The original CBZ is never modified. @@ -33,6 +34,8 @@ The original CBZ is never modified. Windows is the primary tested platform. The tool also checks the standard macOS Calibre location and can use `calibre-debug` from `PATH` on other systems. A working KFX Output/Kindle Previewer setup is still required. +[Google JPEGli](https://github.com/google/jpegli)'s `cjpegli` executable is an optional requirement only when using `--jpeg-encoder jpegli`. Build JPEGli according to its project instructions, then put `cjpegli` on `PATH`, set `CJPEGLI_PATH`, or pass its location with `--cjpegli`. JPEGli is not bundled with this npm package. + ### Installing KFX Output 1. Install and start Calibre once. @@ -69,7 +72,7 @@ npx cbz2scribecolor "D:\Comics\Issue 001.cbz" ``` > [!NOTE] -> `npx` supplies only the CBZ2ScribeColor Node.js command. You still need Node.js 22.5 or newer, Calibre, the KFX Output plugin installed in Calibre, and Kindle Previewer 3. Ensure `calibre-debug` is discoverable in a standard Calibre installation or on `PATH`; otherwise pass `--calibre-debug ` or set `CALIBRE_DEBUG_PATH`. +> `npx` supplies only the CBZ2ScribeColor Node.js command. You still need Node.js 22.5 or newer, Calibre, the KFX Output plugin installed in Calibre, and Kindle Previewer 3. Ensure `calibre-debug` is discoverable in a standard Calibre installation or on `PATH`; otherwise pass `--calibre-debug ` or set `CALIBRE_DEBUG_PATH`. Selecting JPEGli also requires a separate `cjpegli` installation; `npx` does not provide it. Pass CLI options after the input path in the same way as a global installation: @@ -143,6 +146,20 @@ Force right-to-left manga reading and retain the generated KPF for inspection: cbz2scribe manga.cbz --direction rtl --keep-kpf ``` +Use JPEGli for a middle ground between compression and speed: + +```bash +cbz2scribe manga.cbz --jpeg-encoder jpegli --cjpegli "C:\Tools\jpegli\cjpegli.exe" +``` + +Use the former, faster JPEG encoder when conversion speed matters more than output size: + +```bash +cbz2scribe manga.cbz --jpeg-encoder legacy +``` + +`--no-mozjpeg` remains a shortcut for `--jpeg-encoder legacy`. + ## Command-line options | Option | Default | Description | @@ -157,6 +174,9 @@ cbz2scribe manga.cbz --direction rtl --keep-kpf | `--calibre-debug ` | Auto-detected | Full path to Calibre's `calibre-debug` executable. | | `-j, --jobs ` | `12` | Maximum number of CBZ files prepared concurrently. Each completed KPF is then compiled through KFX Output. | | `--keep-kpf` | Off | Keep the intermediate Kindle Publishing Format package after successful KFX compilation. Failed conversions retain their KPF for diagnosis. | +| `--jpeg-encoder ` | `mozjpeg` | Select tuned MozJPEG, external Google JPEGli, or the former fast JPEG encoder. | +| `--cjpegli ` | Auto-detected | Full path to `cjpegli`. Used only with `--jpeg-encoder jpegli`; otherwise checks `CJPEGLI_PATH` and then `PATH`. | +| `--no-mozjpeg` | Off | Shortcut for `--jpeg-encoder legacy`. Cannot be combined with `--jpeg-encoder`. | | `-h, --help` | — | Show CLI help. | Existing destination KFX files with the same name are replaced. When processing multiple files, successful conversions are retained even if another input fails; the command exits non-zero and reports every failed filename at the end. @@ -193,6 +213,20 @@ A source image whose width-to-height ratio meets `--wide-ratio` is treated as a KFX image resources contain only the resized artwork bounds. Letterboxing is provided by the black logical page background rather than being baked into those resources. Kindle's synthetic four-corner Virtual Panels therefore divide and magnify the artwork rather than including top, bottom, or outside-edge bars. +## JPEG compression + +Page artwork is encoded by default with Sharp's built-in MozJPEG mode using progressive scans and 4:4:4 chroma sampling. JPEGli uses distance 0.8, progressive level 2, and 4:4:4 chroma. Both profiles were calibrated against the previous quality-90 JPEG encoder rather than assuming that numeric quality settings are comparable across encoders. MozJPEG needs no additional executable; JPEGli invokes a separately installed `cjpegli` process and streams each resized page through it without temporary files. + +An encoder-only benchmark on this project's primary Windows test PC used `cjpegli` 0.11.2 and three full comics: 82 source images producing 87 physical pages, including five split spreads. Every candidate encoded the same resized, uncompressed pixels. JPEGli's timing includes process startup and streaming for every page. + +| Encoder | Page data | JPEG encode time | PSNR | Mean global SSIM | +| --- | ---: | ---: | ---: | ---: | +| Previous quality-90, 4:4:4 JPEG | 123.28 MiB | 3.5 s | 40.12 dB | 0.99958 | +| Tuned MozJPEG, 4:4:4 | 98.57 MiB | 54.5 s | 40.91 dB | 0.99966 | +| JPEGli distance 0.8, 4:4:4 | 110.80 MiB | 10.0 s | 40.24 dB | 0.99974 | + +In this sample, MozJPEG made the image payload **20.0% smaller** and took **15.6 times** as long to encode. JPEGli made it **10.1% smaller** and took **2.86 times** as long as legacy; it was **5.47 times faster than MozJPEG**. All three calibrated profiles met or exceeded the legacy fidelity scores in this sample. Total conversion slowdowns will be lower because CBZ reading, image decoding and resizing, KPF assembly, KFX compilation, and metadata writing are unchanged. Finished KPF/KFX savings vary with artwork and non-image container overhead. + ## Reading direction Direction affects both page turns and the order of split spread halves: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 77089a5..aa9010f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,4 +2,6 @@ The native Node KPF writer in `src/kindle-kpf.ts` is based on format research and the MIT-licensed implementation published in [HankunYu/kindle-comic-workaround-5.19.x](https://github.com/HankunYu/kindle-comic-workaround-5.19.x). It has been independently adapted to TypeScript and extended for explicit source-spread pairing, Scribe Colorsoft artwork bounds, black fixed-layout backgrounds, and Kindle Virtual Panel metadata. +Optional JPEGli encoding invokes a separately installed [`cjpegli`](https://github.com/google/jpegli) executable. JPEGli is distributed by Google under the BSD 3-Clause License and is not bundled with CBZ2ScribeColor. + The generated KPF/KFX structures use reverse-engineered, undocumented Amazon Kindle formats. Amazon, Kindle, Kindle Create, Kindle Previewer, and KFX are trademarks of Amazon.com, Inc. or its affiliates. This project is independent and is not affiliated with or endorsed by Amazon. diff --git a/package.json b/package.json index c4c3180..dd40ece 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "cbz2scribecolor": "dist/cli.js" }, "scripts": { - "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "npm run clean && tsc -p tsconfig.json", "prepack": "npm run build", "start": "node dist/cli.js", "test": "vitest run", @@ -40,6 +41,7 @@ "kindle", "kfx", "comic", + "jpegli", "kindle-scribe", "colorsoft" ], diff --git a/src/cbz.test.ts b/src/cbz.test.ts index cccccf5..e73f801 100644 --- a/src/cbz.test.ts +++ b/src/cbz.test.ts @@ -36,6 +36,10 @@ describe("CBZ to KPF preparation", () => { expect([single.width, single.height]).toEqual([1986, 1986]); expect([firstHalf.width, firstHalf.height]).toEqual([1765, 2648]); expect([secondHalf.width, secondHalf.height]).toEqual([1765, 2648]); + expect([single.isProgressive, firstHalf.isProgressive, secondHalf.isProgressive]) + .toEqual([true, true, true]); + expect([single.chromaSubsampling, firstHalf.chromaSubsampling, secondHalf.chromaSubsampling]) + .toEqual(["4:4:4", "4:4:4", "4:4:4"]); const kcb = JSON.parse(kpf.readAsText("book.kcb")) as { book_state: { book_reading_option: number; book_virtual_panelmovement: number } }; expect(kcb.book_state.book_reading_option).toBe(2); expect(kcb.book_state.book_virtual_panelmovement).toBe(1); @@ -59,6 +63,31 @@ describe("CBZ to KPF preparation", () => { expect(plan.direction).toBe("rtl"); expect(plan.comicInfoFound).toBe(false); }); + + it("can use the faster legacy JPEG encoder", async () => { + const directory = await temporaryDirectory(); + const input = join(directory, "Fast.cbz"); + const output = join(directory, "Fast.kpf"); + await createCbz(input, undefined, [[1000, 1500]]); + + await createKpfFromCbz(input, output, { jpegEncoder: "legacy" }); + + const page = await sharp(new AdmZip(output).getEntry("book_1.jpg")?.getData()).metadata(); + expect(page.isProgressive).toBe(false); + expect(page.chromaSubsampling).toBe("4:4:4"); + }); + + it("reports a missing JPEGli executable", async () => { + const directory = await temporaryDirectory(); + const input = join(directory, "Jpegli.cbz"); + const output = join(directory, "Jpegli.kpf"); + await createCbz(input, undefined, [[100, 150]]); + + await expect(createKpfFromCbz(input, output, { + jpegEncoder: "jpegli", + cjpegliPath: join(directory, "missing-cjpegli"), + })).rejects.toThrow("Could not start cjpegli"); + }); }); async function temporaryDirectory(): Promise { diff --git a/src/cbz.ts b/src/cbz.ts index b897c68..081ec7d 100644 --- a/src/cbz.ts +++ b/src/cbz.ts @@ -2,16 +2,20 @@ import { mkdir } from "node:fs/promises"; import { basename, dirname, extname, resolve } from "node:path"; import AdmZip from "adm-zip"; import sharp from "sharp"; +import { encodeJpegli } from "./jpegli.js"; import { createComicKpf, type KpfPage } from "./kindle-kpf.js"; export const SCRIBE_COLORSOFT_WIDTH = 1986; export const SCRIBE_COLORSOFT_HEIGHT = 2648; export type ReadingDirection = "ltr" | "rtl"; +export type PageJpegEncoder = "mozjpeg" | "jpegli" | "legacy"; export interface CbzOptions { direction?: ReadingDirection | "auto"; wideRatio?: number; + jpegEncoder?: PageJpegEncoder; + cjpegliPath?: string; } export interface BookMetadata { @@ -64,6 +68,17 @@ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".av const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); const DEFAULT_WIDE_RATIO = 1.125; const PAGE_BACKGROUND = "#000000"; +// Table 1 at quality 76 matched or exceeded the former quality-90 encoder in the project benchmark. +const MOZJPEG_OPTIONS = { + quality: 76, + chromaSubsampling: "4:4:4", + mozjpeg: true, + quantisationTable: 1, +} as const; +const FAST_JPEG_OPTIONS = { + quality: 90, + chromaSubsampling: "4:4:4", +} as const; export async function planCbz(inputPath: string, options: CbzOptions = {}): Promise { return (await inspectCbz(resolve(inputPath), options)).plan; @@ -75,7 +90,11 @@ export async function createKpfFromCbz( options: CbzOptions = {}, ): Promise { const inspection = await inspectCbz(resolve(inputPath), options); - const pages = await preparePages(inspection); + const jpegEncoder = options.jpegEncoder ?? "mozjpeg"; + if (!["mozjpeg", "jpegli", "legacy"].includes(jpegEncoder)) { + throw new Error(`Unsupported JPEG encoder: ${jpegEncoder}`); + } + const pages = await preparePages(inspection, jpegEncoder, options.cjpegliPath); const destination = resolve(outputPath); await mkdir(dirname(destination), { recursive: true }); await createComicKpf(destination, pages, { @@ -140,7 +159,11 @@ async function inspectCbz(inputPath: string, options: CbzOptions): Promise { +async function preparePages( + inspection: Inspection, + jpegEncoder: PageJpegEncoder, + cjpegliPath?: string, +): Promise { const pages: KpfPage[] = []; for (const [sourceIndex, source] of inspection.plan.sourcePages.entries()) { const entry = inspection.imageEntries[sourceIndex]; @@ -150,8 +173,11 @@ async function preparePages(inspection: Inspection): Promise { if (!source.doublePage) { const bounds = containBounds(source.width, source.height); pages.push({ - data: await pipeline.resize(bounds.width, bounds.height, { fit: "fill" }) - .jpeg({ quality: 90, chromaSubsampling: "4:4:4" }).toBuffer(), + data: await encodePage( + pipeline.resize(bounds.width, bounds.height, { fit: "fill" }), + jpegEncoder, + cjpegliPath, + ), width: bounds.width, height: bounds.height, sourceName: source.sourceName, @@ -174,10 +200,13 @@ async function preparePages(inspection: Inspection): Promise { for (const half of halves) { const bounds = containBounds(half.width, source.height); pages.push({ - data: await pipeline.clone() - .extract({ left: half.left, top: 0, width: half.width, height: source.height }) - .resize(bounds.width, bounds.height, { fit: "fill" }) - .jpeg({ quality: 90, chromaSubsampling: "4:4:4" }).toBuffer(), + data: await encodePage( + pipeline.clone() + .extract({ left: half.left, top: 0, width: half.width, height: source.height }) + .resize(bounds.width, bounds.height, { fit: "fill" }), + jpegEncoder, + cjpegliPath, + ), width: bounds.width, height: bounds.height, sourceName: `${source.sourceName}#${half.side}`, @@ -188,6 +217,19 @@ async function preparePages(inspection: Inspection): Promise { return pages; } +async function encodePage( + pipeline: ReturnType, + jpegEncoder: PageJpegEncoder, + cjpegliPath?: string, +): Promise { + if (jpegEncoder === "mozjpeg") return pipeline.jpeg(MOZJPEG_OPTIONS).toBuffer(); + if (jpegEncoder === "legacy") return pipeline.jpeg(FAST_JPEG_OPTIONS).toBuffer(); + + const { data, info } = await pipeline.removeAlpha().raw().toBuffer({ resolveWithObject: true }); + if (info.channels !== 3) throw new Error(`JPEGli requires RGB input; Sharp produced ${info.channels} channels`); + return encodeJpegli(data, info.width, info.height, cjpegliPath ?? process.env.CJPEGLI_PATH ?? "cjpegli"); +} + function containBounds(sourceWidth: number, sourceHeight: number): Bounds { const scale = Math.min(SCRIBE_COLORSOFT_WIDTH / sourceWidth, SCRIBE_COLORSOFT_HEIGHT / sourceHeight); return { diff --git a/src/cli.ts b/src/cli.ts index 6abe80a..ade10d1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,8 +2,9 @@ import { readdir, rm, stat } from "node:fs/promises"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { Command, Option } from "commander"; -import { createKpfFromCbz, planCbz, type ConversionPlan, type ReadingDirection } from "./cbz.js"; +import { createKpfFromCbz, planCbz, type ConversionPlan, type PageJpegEncoder, type ReadingDirection } from "./cbz.js"; import { runWithConcurrency } from "./concurrency.js"; +import { findCjpegli } from "./jpegli.js"; import { compileKfx, findKfxOutput } from "./kfx-output.js"; interface CliOptions { @@ -16,6 +17,9 @@ interface CliOptions { calibreDebug?: string; jobs: string; keepKpf: boolean; + jpegEncoder: PageJpegEncoder; + cjpegli?: string; + mozjpeg: boolean; } const program = new Command() @@ -31,6 +35,9 @@ const program = new Command() .option("--calibre-debug ", "explicit path to calibre-debug with the KFX Output plugin") .option("-j, --jobs ", "maximum files processed concurrently", "12") .option("--keep-kpf", "retain the generated KPF source package", false) + .addOption(new Option("--jpeg-encoder ", "page JPEG encoder").choices(["mozjpeg", "jpegli", "legacy"]).default("mozjpeg")) + .option("--cjpegli ", "explicit path to cjpegli for --jpeg-encoder jpegli") + .option("--no-mozjpeg", "shortcut for --jpeg-encoder legacy") .showHelpAfterError() .parse(); @@ -48,6 +55,10 @@ try { async function run(inputPath: string, options: CliOptions): Promise { const wideRatio = positiveNumber(options.wideRatio, "wide-ratio"); const jobs = positiveInteger(options.jobs, "jobs"); + const jpegEncoder = options.mozjpeg ? options.jpegEncoder : "legacy"; + if (!options.mozjpeg && program.getOptionValueSource("jpegEncoder") === "cli") { + throw new Error("--no-mozjpeg cannot be combined with --jpeg-encoder"); + } const inputInfo = await stat(inputPath); const sources = await findCbzFiles(inputPath, inputInfo.isDirectory(), options.recursive); if (sources.length === 0) throw new Error("No CBZ files found"); @@ -58,6 +69,13 @@ async function run(inputPath: string, options: CliOptions): Promise { } if (calibreDebug) console.log(`Using KFX Output through: ${calibreDebug}`); + const cjpegli = !options.dryRun && jpegEncoder === "jpegli" ? await findCjpegli(options.cjpegli) : undefined; + if (!options.dryRun && jpegEncoder === "jpegli" && !cjpegli) { + throw new Error("cjpegli was not found. Install Google JPEGli and put cjpegli on PATH, set CJPEGLI_PATH, or pass --cjpegli ."); + } + if (cjpegli) console.log(`Using JPEGli through: ${cjpegli}`); + if (!options.dryRun) console.log(`JPEG encoder: ${jpegEncoder}`); + const outputRoot = resolve(options.output); const failures: string[] = []; const concurrency = Math.min(jobs, sources.length); @@ -68,7 +86,12 @@ async function run(inputPath: string, options: CliOptions): Promise { const kfxPath = outputPathFor(source, inputPath, inputInfo.isDirectory(), outputRoot, options.inPlace, ".kfx"); const kpfPath = outputPathFor(source, inputPath, inputInfo.isDirectory(), outputRoot, options.inPlace, ".kpf"); try { - const conversionOptions = { direction: options.direction, wideRatio }; + const conversionOptions = { + direction: options.direction, + wideRatio, + jpegEncoder, + ...(cjpegli ? { cjpegliPath: cjpegli } : {}), + }; const plan = options.dryRun ? await planCbz(source, conversionOptions) : await createKpfFromCbz(source, kpfPath, conversionOptions); printPlan(label, plan, options.dryRun); if (!options.dryRun) { diff --git a/src/jpegli.ts b/src/jpegli.ts new file mode 100644 index 0000000..53964d0 --- /dev/null +++ b/src/jpegli.ts @@ -0,0 +1,106 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; + +const JPEGLI_ARGUMENTS = [ + "-", + "-", + "--distance=0.8", + "--chroma_subsampling=444", + "--progressive_level=2", + "--quiet", +] as const; + +export async function findCjpegli(explicitPath?: string): Promise { + const configured = [explicitPath, process.env.CJPEGLI_PATH] + .filter((value): value is string => Boolean(value)); + for (const candidate of configured) { + const absolute = resolve(candidate); + try { + await access(absolute, constants.X_OK); + if (await isCjpegli(absolute)) return absolute; + } catch { + // Try the next configured path. + } + } + + return commandExists("cjpegli") && await isCjpegli("cjpegli") ? "cjpegli" : undefined; +} + +export async function encodeJpegli( + pixels: Buffer, + width: number, + height: number, + executable: string, +): Promise { + if (pixels.length !== width * height * 3) { + throw new Error(`JPEGli expected ${width}x${height} RGB pixels, received ${pixels.length} bytes`); + } + + return new Promise((resolvePromise, reject) => { + const child = spawn(executable, [...JPEGLI_ARGUMENTS], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const output: Buffer[] = []; + let errors = ""; + let settled = false; + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => { errors += chunk.toString(); }); + child.stdin.on("error", () => { + // A failed process can close stdin before Node finishes writing the page. + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + reject(new Error(`Could not start cjpegli at ${executable}: ${error.message}`)); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + const jpeg = Buffer.concat(output); + if (code !== 0) { + reject(new Error(`cjpegli exited with ${code ?? -1}.\n${errors.slice(-4000)}`)); + } else if (jpeg.length === 0) { + reject(new Error("cjpegli produced no JPEG data")); + } else { + resolvePromise(jpeg); + } + }); + child.stdin.write(`P6\n${width} ${height}\n255\n`); + child.stdin.end(pixels); + }); +} + +async function isCjpegli(executable: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn(executable, ["--help"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let output = ""; + let settled = false; + const finish = (found: boolean): void => { + if (settled) return; + settled = true; + resolvePromise(found); + }; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.on("error", () => finish(false)); + child.on("close", (code) => finish(code === 0 && /cjpegli|compressed JPEG/iu.test(output))); + }); +} + +function commandExists(command: string): boolean { + const names = process.platform === "win32" ? [`${command}.exe`, command] : [command]; + return (process.env.PATH ?? "").split(delimiter).some((directory) => names.some((name) => { + try { + accessSync(join(directory, name), constants.X_OK); + return true; + } catch { + return false; + } + })); +}