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
This commit is contained in:
2026-04-04 13:38:01 -04:00
parent 6c876c3e19
commit 3c9a661941
4 changed files with 84 additions and 48 deletions
+81
View File
@@ -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<ComicReader> {
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<MangaReader> {
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<Blob[]> {
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;
}
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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<HTMLImageElement> {
const mediaItemId = document.body.dataset.mediaItemId;
-46
View File
@@ -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<void> {
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<Panel[]> {
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,
}));
}