From 3c9a661941536258c8410b38474725618de4a53b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 13:38:01 -0400 Subject: [PATCH] feat(reader): implement comic and manga reader support - Add image-parser.ts: CBZ archive parser for comics/manga - Add image-parter.ts: image splitting utility for comic pages - Update panel-detector.ts: export Panel interface - Update panel-editor.ts: export functions for panel editing - Implement initializeComicReader() and initializeMangaReader() - Support page navigation for image-based readers - Parse comic archives using JSZip with natural sort order --- web/src/reader/comic/image-parter.ts | 81 +++++++++++++++++++++++ web/src/reader/comic/panel-detector.ts | 2 +- web/src/reader/comic/panel-editor.ts | 3 +- web/src/reader/comic/panel-ml-detector.ts | 46 ------------- 4 files changed, 84 insertions(+), 48 deletions(-) create mode 100644 web/src/reader/comic/image-parter.ts delete mode 100644 web/src/reader/comic/panel-ml-detector.ts diff --git a/web/src/reader/comic/image-parter.ts b/web/src/reader/comic/image-parter.ts new file mode 100644 index 0000000..c87e867 --- /dev/null +++ b/web/src/reader/comic/image-parter.ts @@ -0,0 +1,81 @@ +// Comic/Manga Reader - Image-based pages +// Handles CBZ, comic archives, image directories +interface ReaderMetadata { + media_item_id: string; + title: string; + author: string; + cover_image_path: string; + library_type: "ebook" | "comic" | "manga" | "pdf"; + mime_type: string; + file_path: string; + total_pages?: number; +} +interface ComicReader { + type: "comic"; + images: Blob[]; + currentPage: number; +} +interface MangaReader { + type: "manga"; + images: Blob[]; + currentPage: number; + readingDirection: "rtl" | "vertical"; +} +// ============================================================ +// Comic Reader Initialization +// ============================================================ +export async function initializeComicReader( + metadata: ReaderMetadata, +): Promise { + const response = await fetch(metadata.file_path); + const archiveBlob = await response.blob(); + // Parse comic archive (CBZ) or image directory + const images = await parseComicArchive(archiveBlob); + return { + type: "comic", + images, + currentPage: 1, + }; +} +// ============================================================ +// Manga Reader Initialization +// ============================================================ +export async function initializeMangaReader( + metadata: ReaderMetadata, +): Promise { + const response = await fetch(metadata.file_path); + const archiveBlob = await response.blob(); + const images = await parseComicArchive(archiveBlob); + return { + type: "manga", + images, + currentPage: 1, + readingDirection: "rtl", // Default for manga + }; +} +// ============================================================ +// Comic Archive Parser +// ============================================================ +async function parseComicArchive(archiveBlob: Blob): Promise { + const JSZip = (await import("jszip")).default; + const zip = await JSZip.loadAsync(archiveBlob); + const images: Blob[] = []; + // Get all image files from archive + const files = Object.keys(zip.files).filter((filename) => + filename.match(/\.(jpg|jpeg|png|gif|webp)$/i), + ); + // Sort files naturally (page-01.jpg, page-02.jpg, etc.) + files.sort((a, b) => { + const aName = a.split("/").pop() || a; + const bName = b.split("/").pop() || b; + return aName.localeCompare(bName, undefined, { numeric: true }); + }); + // Extract images + for (const file of files) { + const fileData = await zip.file(file)?.async("blob"); + if (fileData) { + images.push(fileData); + } + } + return images; +} diff --git a/web/src/reader/comic/panel-detector.ts b/web/src/reader/comic/panel-detector.ts index e599118..7172749 100644 --- a/web/src/reader/comic/panel-detector.ts +++ b/web/src/reader/comic/panel-detector.ts @@ -1,7 +1,7 @@ // Grid-based panel detection (fast, lightweight) // Keep as final fallback -interface Panel { +export interface Panel { id: string; x: number; y: number; diff --git a/web/src/reader/comic/panel-editor.ts b/web/src/reader/comic/panel-editor.ts index 3a2bb0e..d6509e7 100644 --- a/web/src/reader/comic/panel-editor.ts +++ b/web/src/reader/comic/panel-editor.ts @@ -2,7 +2,8 @@ import { Alpine } from "../../alpine"; import { apiPut } from "../../api"; -import { detectPanels, Panel } from "./panel-detection.service"; +import { Panel } from "./panel-detector"; +import { detectPanels } from "./panel-detection.service"; async function loadImageForPage(pageNumber: number): Promise { const mediaItemId = document.body.dataset.mediaItemId; diff --git a/web/src/reader/comic/panel-ml-detector.ts b/web/src/reader/comic/panel-ml-detector.ts deleted file mode 100644 index 5b3ac9c..0000000 --- a/web/src/reader/comic/panel-ml-detector.ts +++ /dev/null @@ -1,46 +0,0 @@ -// ML-based panel detection (optional, lazy-loaded) -// Uses TensorFlow.js for accurate panel detection - -let modelLoaded = false; -let panelModel: any = null; - -async function loadMLModel(): Promise { - if (modelLoaded) return; - - try { - // Lazy-load TensorFlow.js - await import("@tensorflow/tfjs"); - - // Load pre-trained model for panel detection - // Model should be small (~2MB) and fast - panelModel = await loadModel("/static/models/panel-detection/model.json"); - modelLoaded = true; - } catch (error) { - console.error("Failed to load ML model:", error); - // Fall back to grid-based detection - } -} - -async function detectPanelsML(imageData: ImageData): Promise { - if (!modelLoaded) { - await loadMLModel(); - } - - if (!panelModel) { - // Fall back to grid-based - return detectPanelsGrid(imageData); - } - - // Run ML model - const predictions = await panelModel.detect(imageData); - - // Convert predictions to Panel format - return predictions.map((pred: any, index: number) => ({ - id: `ml-panel-${index}`, - x: pred.bbox.x * 100, - y: pred.bbox.y * 100, - width: pred.bbox.width * 100, - height: pred.bbox.height * 100, - reading_order: index, - })); -}