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:
@@ -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,7 +1,7 @@
|
|||||||
// Grid-based panel detection (fast, lightweight)
|
// Grid-based panel detection (fast, lightweight)
|
||||||
// Keep as final fallback
|
// Keep as final fallback
|
||||||
|
|
||||||
interface Panel {
|
export interface Panel {
|
||||||
id: string;
|
id: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { Alpine } from "../../alpine";
|
import { Alpine } from "../../alpine";
|
||||||
import { apiPut } from "../../api";
|
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> {
|
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||||
const mediaItemId = document.body.dataset.mediaItemId;
|
const mediaItemId = document.body.dataset.mediaItemId;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user