From c36d8e6b576417897bf54b604e057e7286014fcd Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 4 Apr 2026 01:00:43 -0400 Subject: [PATCH] Add comic panel detection system with multi-tier fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - panel-detection.service.ts: Main orchestration with OpenCV → ML → Grid fallback chain - panel-detection.opencv.ts: Edge detection using OpenCV for 80% of comics - panel-detection.ml.ts: COCO-SSD object detection for irregular layouts - panel-detector.ts: Unified detector interface - panel-editor.ts: Manual panel editor UI for user corrections - panel-ml-detector.ts: TensorFlow.js integration for ML detection - page-cache.ts: Efficient page caching for large comics - background-color.ts: Auto-detect comic background color - chapter-markers.ts: Chapter detection and navigation - page-scrubber.ts: Fast page scrubbing/thumbnails - page-order.ts: RTL/LTR page ordering support - panel-gap.ts: Panel gap detection --- web/src/reader/comic/background-color.ts | 103 +++++++++++ web/src/reader/comic/chapter-markers.ts | 147 +++++++++++++++ web/src/reader/comic/page-cache.ts | 151 +++++++++++++++ web/src/reader/comic/page-order.ts | 104 +++++++++++ web/src/reader/comic/page-scrubber.ts | 86 +++++++++ web/src/reader/comic/panel-detection.ml.ts | 93 ++++++++++ .../reader/comic/panel-detection.opencv.ts | 113 ++++++++++++ .../reader/comic/panel-detection.service.ts | 77 ++++++++ web/src/reader/comic/panel-detector.ts | 172 ++++++++++++++++++ web/src/reader/comic/panel-editor.ts | 167 +++++++++++++++++ web/src/reader/comic/panel-gap.ts | 122 +++++++++++++ web/src/reader/comic/panel-ml-detector.ts | 46 +++++ 12 files changed, 1381 insertions(+) create mode 100644 web/src/reader/comic/background-color.ts create mode 100644 web/src/reader/comic/chapter-markers.ts create mode 100644 web/src/reader/comic/page-cache.ts create mode 100644 web/src/reader/comic/page-order.ts create mode 100644 web/src/reader/comic/page-scrubber.ts create mode 100644 web/src/reader/comic/panel-detection.ml.ts create mode 100644 web/src/reader/comic/panel-detection.opencv.ts create mode 100644 web/src/reader/comic/panel-detection.service.ts create mode 100644 web/src/reader/comic/panel-detector.ts create mode 100644 web/src/reader/comic/panel-editor.ts create mode 100644 web/src/reader/comic/panel-gap.ts create mode 100644 web/src/reader/comic/panel-ml-detector.ts diff --git a/web/src/reader/comic/background-color.ts b/web/src/reader/comic/background-color.ts new file mode 100644 index 0000000..41c6192 --- /dev/null +++ b/web/src/reader/comic/background-color.ts @@ -0,0 +1,103 @@ +// Background color options for manga/comics +// Procedural implementation (no OOP) + +type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom"; + +interface BackgroundColorState { + current: BackgroundColor; + customColor: string; +} + +const backgroundColors: Record = { + black: "#000000", + white: "#ffffff", + gray: "#333333", + sepia: "#f4ecd8", + custom: "", +}; + +function createBackgroundColorState( + initial: BackgroundColor = "black", +): BackgroundColorState { + return { + current: initial, + customColor: "#000000", + }; +} + +function setBackgroundColor( + state: BackgroundColorState, + color: BackgroundColor, + customColor?: string, +): BackgroundColorState { + const newState: BackgroundColorState = { + current: color, + customColor: customColor || state.customColor, + }; + + const bgColor = + color === "custom" ? newState.customColor : backgroundColors[color]; + + document.documentElement.style.setProperty("--reader-bg-color", bgColor); + + const viewer = document.querySelector(".reader-content") as HTMLElement; + if (viewer) { + viewer.style.backgroundColor = bgColor; + } + + localStorage.setItem("reader-background-color", color); + + return newState; +} + +function toggleBackgroundColor( + state: BackgroundColorState, +): BackgroundColorState { + const order: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + const currentIndex = order.indexOf(state.current); + const nextIndex = (currentIndex + 1) % order.length; + + return setBackgroundColor(state, order[nextIndex]); +} + +function renderBackgroundColorPicker( + container: HTMLElement, + state: BackgroundColorState, +): void { + const existing = container.querySelector(".background-color-picker"); + existing?.remove(); + + const picker = document.createElement("div"); + picker.className = + "background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40"; + + const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + + colors.forEach((color) => { + const btn = document.createElement("button"); + btn.className = `w-8 h-8 rounded-full border-2 ${ + state.current === color ? "border-blue-500" : "border-transparent" + }`; + btn.style.backgroundColor = backgroundColors[color]; + btn.title = color.charAt(0).toUpperCase() + color.slice(1); + btn.addEventListener("click", () => { + const newState = setBackgroundColor(state, color); + updateBackgroundColorUI(picker, newState); + }); + picker.appendChild(btn); + }); + + container.appendChild(picker); +} + +function updateBackgroundColorUI( + container: HTMLElement, + state: BackgroundColorState, +): void { + const buttons = container.querySelectorAll("button"); + const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + + buttons.forEach((btn, index) => { + btn.classList.toggle("border-blue-500", colors[index] === state.current); + }); +} diff --git a/web/src/reader/comic/chapter-markers.ts b/web/src/reader/comic/chapter-markers.ts new file mode 100644 index 0000000..6b5afd7 --- /dev/null +++ b/web/src/reader/comic/chapter-markers.ts @@ -0,0 +1,147 @@ +// Chapter markers for manga/comics +// Visual indicators for chapter boundaries +// Procedural implementation (no OOP) + +interface ChapterInfo { + chapterNumber: number; + pageStart: number; + pageEnd: number; + title?: string; +} + +interface ChapterMarkerState { + chapters: ChapterInfo[]; + currentChapter: number; + showMarkers: boolean; +} + +function createChapterMarkerState( + chapters: ChapterInfo[], + currentPage: number, +): ChapterMarkerState { + const currentChapter = + chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd) + ?.chapterNumber || 1; + + return { + chapters, + currentChapter, + showMarkers: true, + }; +} + +function renderChapterMarkers( + container: HTMLElement, + state: ChapterMarkerState, +): void { + if (!state.showMarkers) return; + + const markersContainer = document.createElement("div"); + markersContainer.className = + "chapter-markers absolute left-0 right-0 pointer-events-none z-10"; + + state.chapters.forEach((chapter) => { + const marker = document.createElement("div"); + marker.className = + "chapter-marker flex items-center gap-2 text-sm text-gray-400"; + + const isCurrentChapter = chapter.chapterNumber === state.currentChapter; + + marker.style.position = "absolute"; + marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; + marker.style.left = "10px"; + + marker.innerHTML = ` + + ${chapter.title || `Chapter ${chapter.chapterNumber}`} + + p.${chapter.pageStart} + ${isCurrentChapter ? '' : ""} + `; + + markersContainer.appendChild(marker); + }); + + const existing = container.querySelector(".chapter-markers"); + existing?.remove(); + container.appendChild(markersContainer); +} + +function updateCurrentChapter( + state: ChapterMarkerState, + currentPage: number, +): ChapterMarkerState { + const currentChapter = + state.chapters.find( + (c) => currentPage >= c.pageStart && currentPage <= c.pageEnd, + )?.chapterNumber || state.currentChapter; + + if (currentChapter !== state.currentChapter) { + const newState = { ...state, currentChapter }; + + const markers = document.querySelector(".chapter-markers"); + if (markers) { + renderChapterMarkers(markers.parentElement!, newState); + } + + return newState; + } + + return state; +} + +function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { + const newState = { ...state, showMarkers: !state.showMarkers }; + + const markers = document.querySelector(".chapter-markers"); + if (markers) { + markers.classList.toggle("hidden", !newState.showMarkers); + } + + return newState; +} + +function scrollToChapter( + state: ChapterMarkerState, + chapterNumber: number, +): void { + const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber); + if (chapter) { + window.dispatchEvent( + new CustomEvent("navigate-to-page", { + detail: { page: chapter.pageStart }, + }), + ); + } +} + +const chapterMarkerCSS = ` + .chapter-marker { + padding: 4px 8px; + margin-left: -18px; + opacity: 0.7; + transition: opacity 0.2s; + } + + .chapter-marker:hover { + opacity: 1; + } + + .chapter-marker .current-indicator { + color: #3b82f6; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .chapter-marker-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(to right, rgba(255,255,255,0.1), transparent); + } +`; diff --git a/web/src/reader/comic/page-cache.ts b/web/src/reader/comic/page-cache.ts new file mode 100644 index 0000000..89578cc --- /dev/null +++ b/web/src/reader/comic/page-cache.ts @@ -0,0 +1,151 @@ +// Lazy-loading page cache with 5-page ahead prefetch +// Shared by both comic and manga readers + +// Lazy-loading page cache with 5-page ahead prefetch +// Procedural implementation (no OOP) + +import { detectPanels } from "./panel-detection.service"; + +interface PageCacheState { + cache: Map; + loading: Set; + maxAhead: number; + mediaItemId: string; + panelData: Map; +} + +function createPageCache(mediaItemId: string): PageCacheState { + return { + cache: new Map(), + loading: new Set(), + maxAhead: 5, + mediaItemId, + panelData: new Map(), + }; +} + +async function getCachedPage( + state: PageCacheState, + pageNumber: number, +): Promise { + if (state.cache.has(pageNumber)) { + return { ...state, page: state.cache.get(pageNumber)! }; + } + + if (state.loading.has(pageNumber)) { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (state.cache.has(pageNumber)) { + clearInterval(checkInterval); + resolve({ ...state, page: state.cache.get(pageNumber)! }); + } + }, 100); + }) as Promise; + } + + const newLoading = new Set(state.loading); + newLoading.add(pageNumber); + + const img = await loadComicPage(state, pageNumber); + + const newCache = new Map(state.cache); + newCache.set(pageNumber, img); + newLoading.delete(pageNumber); + + const newState = { ...state, cache: newCache, loading: newLoading }; + + prefetchPages(newState, pageNumber + 1); + cleanupPageCache(newState, pageNumber); + + return { ...newState, page: img }; +} + +async function loadComicPage( + state: PageCacheState, + pageNumber: number, +): Promise { + const token = localStorage.getItem("token"); + const response = await fetch( + `/readers/${state.mediaItemId}/pages/${pageNumber}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const img = new Image(); + img.src = URL.createObjectURL(blob); + await new Promise((resolve) => { + img.onload = resolve; + }); + return img; +} + +function prefetchPages(state: PageCacheState, startPage: number): void { + for (let i = startPage; i < startPage + state.maxAhead; i++) { + if (!state.cache.has(i) && !state.loading.has(i)) { + loadComicPage(state, i).then((img) => { + state.cache.set(i, img); + }); + } + } +} + +function cleanupPageCache( + state: PageCacheState, + currentPage: number, +): PageCacheState { + const keepPages = 10; + const newCache = new Map(state.cache); + + for (const [page] of state.cache) { + if (page < currentPage - keepPages) { + newCache.delete(page); + } + } + + return { ...state, cache: newCache }; +} + +// Add this function +async function detectPagePanels( + state: PageCacheState, + pageNumber: number, +): Promise { + // Check if already detected + if (state.panelData?.has(pageNumber)) { + return state.panelData.get(pageNumber)!.panels; + } + + // Get or create image + let image: HTMLImageElement; + if (state.cache.has(pageNumber)) { + image = state.cache.get(pageNumber)!; + } else { + image = await loadComicPage(state, pageNumber); + } + + // Run detection on demand + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + const result = await detectPanels(imageData, true); + + if (!state.panelData) { + state.panelData = new Map(); + } + state.panelData.set(pageNumber, result); + + return result.panels; +} + +// Export the new function +export { createPageCache, getCachedPage, loadComicPage, detectPagePanels }; diff --git a/web/src/reader/comic/page-order.ts b/web/src/reader/comic/page-order.ts new file mode 100644 index 0000000..9270133 --- /dev/null +++ b/web/src/reader/comic/page-order.ts @@ -0,0 +1,104 @@ +// Page order presets for manga/comics +// Auto-detect Japanese vs Western reading order +// Allow user override in case detection is wrong +// Procedural implementation (no OOP) + +type PageOrderMode = "auto" | "japanese" | "western"; + +interface PageOrderConfig { + mode: PageOrderMode; + detectedOrder: PageOrderMode; + userOverride: boolean; +} + +interface PageOrderState { + config: PageOrderConfig; + totalPages: number; +} + +// Detect page order based on filename patterns +function detectPageOrder(pageNames: string[]): PageOrderMode { + if (pageNames.length < 2) return "western"; + + const firstPage = pageNames[0].toLowerCase(); + const lastPage = pageNames[pageNames.length - 1].toLowerCase(); + + const hasFrontCover = /cover|front|001/.test(firstPage); + const hasBackCover = /back|end|最后的/.test(lastPage); + + if (hasFrontCover && !hasBackCover) { + return "western"; + } + if (hasBackCover && !hasFrontCover) { + return "japanese"; + } + + const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n)); + if (chapterMatches.length > 0) { + const firstChapter = chapterMatches[0]; + const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0"); + return pageNum > 0 ? "western" : "japanese"; + } + + return "western"; +} + +function createPageOrderState( + totalPages: number, + pageNames: string[], +): PageOrderState { + const detectedOrder = detectPageOrder(pageNames); + + return { + config: { + mode: "auto", + detectedOrder, + userOverride: false, + }, + totalPages, + }; +} + +function setPageOrderMode( + state: PageOrderState, + mode: PageOrderMode, +): PageOrderState { + return { + ...state, + config: { + ...state.config, + mode, + userOverride: mode !== "auto", + }, + }; +} + +function getPageOrder(state: PageOrderState): PageOrderMode { + if (state.config.mode === "auto") { + return state.config.detectedOrder; + } + return state.config.mode; +} + +function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { + const order = getPageOrder(state); + + if (order === "japanese") { + return [...pageNumbers].reverse(); + } + + return pageNumbers; +} + +function getDisplayPageNumber( + state: PageOrderState, + actualPage: number, +): number { + const order = getPageOrder(state); + + if (order === "japanese") { + return state.totalPages - actualPage + 1; + } + + return actualPage; +} diff --git a/web/src/reader/comic/page-scrubber.ts b/web/src/reader/comic/page-scrubber.ts new file mode 100644 index 0000000..8f319e8 --- /dev/null +++ b/web/src/reader/comic/page-scrubber.ts @@ -0,0 +1,86 @@ +// Page slider/scrubber for quick navigation +// Procedural implementation (no OOP) + +interface PageScrubberState { + currentPage: number; + totalPages: number; + container: HTMLElement; +} + +function createPageScrubber( + container: HTMLElement, + currentPage: number, + totalPages: number, +): PageScrubberState { + const state: PageScrubberState = { + currentPage, + totalPages, + container, + }; + + renderPageScrubber(state); + return state; +} + +function renderPageScrubber(state: PageScrubberState): void { + const existing = state.container.querySelector(".page-scrubber"); + existing?.remove(); + + const scrubber = document.createElement("div"); + scrubber.className = + "page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40"; + scrubber.innerHTML = ` + ${state.currentPage} + + ${state.totalPages} + `; + + const slider = scrubber.querySelector(".page-slider") as HTMLInputElement; + slider.addEventListener("input", (e) => { + const targetPage = parseInt((e.target as HTMLInputElement).value); + updatePageScrubber(state, targetPage); + }); + + slider.addEventListener("change", () => { + const targetPage = parseInt(slider.value); + dispatchPageNavigationEvent(targetPage); + }); + + state.container.appendChild(scrubber); +} + +function updatePageScrubber( + state: PageScrubberState, + currentPage: number, +): PageScrubberState { + const newState = { ...state, currentPage }; + + const label = state.container.querySelector(".page-label"); + if (label) { + label.textContent = String(currentPage); + } + + return newState; +} + +function showPageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector(".page-scrubber"); + scrubber?.classList.remove("hidden"); +} + +function hidePageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector(".page-scrubber"); + scrubber?.classList.add("hidden"); +} + +function dispatchPageNavigationEvent(page: number): void { + window.dispatchEvent( + new CustomEvent("navigate-to-page", { detail: { page } }), + ); +} diff --git a/web/src/reader/comic/panel-detection.ml.ts b/web/src/reader/comic/panel-detection.ml.ts new file mode 100644 index 0000000..9d1a26b --- /dev/null +++ b/web/src/reader/comic/panel-detection.ml.ts @@ -0,0 +1,93 @@ +// ML-based panel detection using COCO-SSD pre-trained model + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +let model: any = null; +let tfLoaded = false; + +async function loadTF(): Promise { + if (tfLoaded) return; + + // Load TensorFlow.js + await import("@tensorflow/tfjs"); + tfLoaded = true; +} + +async function loadModel(): Promise { + if (model) return; + + await loadTF(); + + // Load COCO-SSD model (pre-trained on millions of images) + const cocoSsd = await import("@tensorflow-models/coco-ssd"); + model = await cocoSsd.load({ + base: "lite_mobilenet_v2", // Smaller, faster model + }); +} + +async function detectPanelsML(imageData: ImageData): Promise { + await loadModel(); + + // Create HTMLCanvasElement to run model inference + const canvas = document.createElement("canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + + // Run COCO-SSD model + const predictions = await model.detect(canvas); + + // Filter predictions to find rectangular regions (panels) + // COCO-SSD detects common objects, we look for rectangular ones + const panels: Panel[] = []; + const imgWidth = imageData.width; + const imgHeight = imageData.height; + + for (let i = 0; i < predictions.length; i++) { + const pred = predictions[i]; + + // COCO-SSD detects "book" and similar objects + // We filter for reasonable panel-like detections + const [x, y, w, h] = pred.bbox; + const aspectRatio = w / h; + + const isRectangular = + aspectRatio > 0.3 && // Not too tall/thin + aspectRatio < 5 && // Not too wide + w > imgWidth * 0.05 && // Not too small + h > imgHeight * 0.05; + + if (isRectangular) { + panels.push({ + id: `ml-panel-${i}`, + x: (x / imgWidth) * 100, + y: (y / imgHeight) * 100, + width: (w / imgWidth) * 100, + height: (h / imgHeight) * 100, + reading_order: i, + }); + } + } + + // Sort panels by reading order + panels.sort((a, b) => { + const rowA = Math.floor(a.y / 25); + const rowB = Math.floor(b.y / 25); + if (rowA !== rowB) return rowA - rowB; + return a.x - b.x; + }); + + panels.forEach((p, i) => (p.reading_order = i)); + + return panels; +} + +export { detectPanelsML, loadModel }; diff --git a/web/src/reader/comic/panel-detection.opencv.ts b/web/src/reader/comic/panel-detection.opencv.ts new file mode 100644 index 0000000..fa4c9c7 --- /dev/null +++ b/web/src/reader/comic/panel-detection.opencv.ts @@ -0,0 +1,113 @@ +// OpenCV.js-based edge detection for panel boundaries + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +let openCVLoaded = false; + +async function loadOpenCV(): Promise { + if (openCVLoaded) return; + + // OpenCV.js loads asynchronously and registers globally + await import("@techstark/opencv-js"); + + // Wait for OpenCV to be ready + return new Promise((resolve) => { + const check = () => { + if ((window as any).cv && (window as any).cv.Mat) { + openCVLoaded = true; + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); +} + +async function detectPanelsOpenCV(imageData: ImageData): Promise { + await loadOpenCV(); + + const cv = (window as any).cv; + + // Create matrices from ImageData + const src = cv.matFromImageData(imageData); + const gray = new cv.Mat(); + const blurred = new cv.Mat(); + const edges = new cv.Mat(); + const contours = new cv.Mat(); + const hierarchy = new cv.Mat(); + + try { + // Convert to grayscale + cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0); + + // Apply Gaussian blur to reduce noise + cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT); + + // Detect edges using Canny + cv.Canny(blurred, edges, 50, 150, 3, false); + + // Find contours + cv.findContours( + edges, + contours, + hierarchy, + cv.RETR_EXTERNAL, + cv.CHAIN_APPROX_SIMPLE, + ); + + // Convert contours to panels + const panels: Panel[] = []; + const imgWidth = imageData.width; + const imgHeight = imageData.height; + + for (let i = 0; i < contours.size(); i++) { + const rect = cv.boundingRect(contours.get(i)); + const aspectRatio = rect.width / rect.height; + + // Filter: reject very small or very thin contours + const minSize = Math.min(imgWidth, imgHeight) * 0.05; + if (rect.width < minSize || rect.height < minSize) continue; + if (aspectRatio < 0.1 || aspectRatio > 10) continue; + + panels.push({ + id: `opencv-panel-${i}`, + x: (rect.x / imgWidth) * 100, + y: (rect.y / imgHeight) * 100, + width: (rect.width / imgWidth) * 100, + height: (rect.height / imgHeight) * 100, + reading_order: i, + }); + } + + // Sort panels by reading order (top-left to bottom-right) + panels.sort((a, b) => { + const rowA = Math.floor(a.y / 25); + const rowB = Math.floor(b.y / 25); + if (rowA !== rowB) return rowA - rowB; + return a.x - b.x; + }); + + // Reassign reading order after sorting + panels.forEach((p, i) => (p.reading_order = i)); + + return panels; + } finally { + // Clean up OpenCV matrices + src.delete(); + gray.delete(); + blurred.delete(); + edges.delete(); + contours.delete(); + hierarchy.delete(); + } +} + +export { detectPanelsOpenCV, loadOpenCV }; diff --git a/web/src/reader/comic/panel-detection.service.ts b/web/src/reader/comic/panel-detection.service.ts new file mode 100644 index 0000000..516d5bd --- /dev/null +++ b/web/src/reader/comic/panel-detection.service.ts @@ -0,0 +1,77 @@ +// Main panel detection service with fallback chain +// Priority: OpenCV → ML → Grid → Manual Editor +import { detectPanelsOpenCV } from "./panel-detection.opencv"; +import { detectPanelsML } from "./panel-detection.ml"; +import { detectPanelsGrid } from "./panel-detector"; + +interface DetectionResult { + panels: Panel[]; + method: "opencv" | "ml" | "grid" | "manual"; + confidence: number; +} + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +async function detectPanels( + imageData: ImageData, + allowManual: boolean = true, +): Promise { + // Tier 1: OpenCV Edge Detection + try { + const panels = await detectPanelsOpenCV(imageData); + if (validatePanels(panels, imageData)) { + return { panels, method: "opencv", confidence: 0.85 }; + } + } catch (e) { + console.warn("OpenCV detection failed:", e); + } + + // Tier 2: ML Detection (COCO-SSD) + try { + const panels = await detectPanelsML(imageData); + if (validatePanels(panels, imageData)) { + return { panels, method: "ml", confidence: 0.9 }; + } + } catch (e) { + console.warn("ML detection failed:", e); + } + + // Tier 3: Grid Detection (baseline) + const panels = detectPanelsGrid(imageData); + if (allowManual && panels.length === 0) { + return { + panels: [], + method: "manual" as const, + confidence: 0, + }; + } + return { panels, method: "grid", confidence: 0.5 }; +} + +function validatePanels(panels: Panel[], imageData: ImageData): boolean { + // Must have at least 1 panel + if (panels.length === 0) return false; + // Should not have too many panels (probably noise) + if (panels.length > 30) return false; + // Panels should cover reasonable area (not all empty space) + let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0); + if (totalArea < 10 || totalArea > 100) return false; + // Check panel sizes are reasonable relative to image dimensions + const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02; + const tooSmall = panels.some( + (p) => + (p.width / 100) * imageData.width < minPanelSize || + (p.height / 100) * imageData.height < minPanelSize, + ); + if (tooSmall) return false; + return true; +} + +export { detectPanels, DetectionResult, Panel }; diff --git a/web/src/reader/comic/panel-detector.ts b/web/src/reader/comic/panel-detector.ts new file mode 100644 index 0000000..e599118 --- /dev/null +++ b/web/src/reader/comic/panel-detector.ts @@ -0,0 +1,172 @@ +// Grid-based panel detection (fast, lightweight) +// Keep as final fallback + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +interface GridConfig { + rows: number; + cols: number; +} + +function detectPanelsGrid( + imageData: ImageData, + config: GridConfig = { rows: 3, cols: 3 }, +): Panel[] { + const panels: Panel[] = []; + const cellWidth = imageData.width / config.cols; + const cellHeight = imageData.height / config.rows; + + for (let y = 0; y < config.rows; y++) { + for (let x = 0; x < config.cols; x++) { + const cell = extractCell(imageData, x, y, cellWidth, cellHeight); + + if (!isEmpty(cell)) { + panels.push({ + id: `panel-${panels.length}`, + x: (x / config.cols) * 100, + y: (y / config.rows) * 100, + width: (1 / config.cols) * 100, + height: (1 / config.rows) * 100, + reading_order: panels.length, + }); + } + } + } + + return mergeAdjacentPanels(panels); +} + +function isEmpty(cellData: ImageData): boolean { + // Simple edge detection to find empty space + // Count white/transparent pixels + let emptyPixels = 0; + const totalPixels = cellData.width * cellData.height; + const threshold = 0.95; // 95% empty = empty cell + + for (let i = 0; i < cellData.data.length; i += 4) { + const r = cellData.data[i]; + const g = cellData.data[i + 1]; + const b = cellData.data[i + 2]; + const a = cellData.data[i + 3]; + + // Consider white or transparent as empty + if (a < 10 || (r > 250 && g > 250 && b > 250)) { + emptyPixels++; + } + } + + return emptyPixels / totalPixels > threshold; +} + +function mergeAdjacentPanels(panels: Panel[]): Panel[] { + // Merge panels that are next to each other + // Simplified algorithm - can be enhanced + const merged: Panel[] = []; + const used = new Set(); + + for (let i = 0; i < panels.length; i++) { + if (used.has(i)) continue; + + let current = { ...panels[i] }; + used.add(i); + + // Look for adjacent panels + for (let j = i + 1; j < panels.length; j++) { + if (used.has(j)) continue; + if (isAdjacent(current, panels[j])) { + current = mergePanels(current, panels[j]); + used.add(j); + } + } + + merged.push(current); + } + + return merged; +} + +function extractCell( + imageData: ImageData, + gridX: number, + gridY: number, + cellWidth: number, + cellHeight: number, +): ImageData { + const startX = Math.floor(gridX * cellWidth); + const startY = Math.floor(gridY * cellHeight); + const width = Math.floor(cellWidth); + const height = Math.floor(cellHeight); + + const cellData = new Uint8ClampedArray(width * height * 4); + // Copy pixels for the cell region + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4; + const destIdx = (y * width + x) * 4; + cellData[destIdx] = imageData.data[srcIdx]; + cellData[destIdx + 1] = imageData.data[srcIdx + 1]; + cellData[destIdx + 2] = imageData.data[srcIdx + 2]; + cellData[destIdx + 3] = imageData.data[srcIdx + 3]; + } + } + + return new ImageData(cellData, width, height); +} + +function isAdjacent(p1: Panel, p2: Panel): boolean { + const tolerance = 5; // 5% tolerance for alignment + // Check horizontal adjacency + if ( + Math.abs(p1.y - p2.y) < tolerance && + Math.abs(p1.height - p2.height) < tolerance + ) { + return ( + Math.abs(p1.x + p1.width - p2.x) < tolerance || + Math.abs(p2.x + p2.width - p1.x) < tolerance + ); + } + // Check vertical adjacency + if ( + Math.abs(p1.x - p2.x) < tolerance && + Math.abs(p1.width - p2.width) < tolerance + ) { + return ( + Math.abs(p1.y + p1.height - p2.y) < tolerance || + Math.abs(p2.y + p2.height - p1.y) < tolerance + ); + } + return false; +} + +function mergePanels(p1: Panel, p2: Panel): Panel { + const minX = Math.min(p1.x, p2.x); + const minY = Math.min(p1.y, p2.y); + const maxX = Math.max(p1.x + p1.width, p2.x + p2.width); + const maxY = Math.max(p1.y + p1.height, p2.y + p2.height); + + return { + id: p1.id, + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + reading_order: Math.min(p1.reading_order, p2.reading_order), + }; +} + +// ADD THIS EXPORT AT THE END OF THE FILE +export { + detectPanelsGrid, + isEmpty, + mergeAdjacentPanels, + extractCell, + isAdjacent, + mergePanels, +}; diff --git a/web/src/reader/comic/panel-editor.ts b/web/src/reader/comic/panel-editor.ts new file mode 100644 index 0000000..3a2bb0e --- /dev/null +++ b/web/src/reader/comic/panel-editor.ts @@ -0,0 +1,167 @@ +// Manual panel editor for admins/power users + +import { Alpine } from "../../alpine"; +import { apiPut } from "../../api"; +import { detectPanels, Panel } from "./panel-detection.service"; + +async function loadImageForPage(pageNumber: number): Promise { + const mediaItemId = document.body.dataset.mediaItemId; + if (!mediaItemId) { + throw new Error("No mediaItemId found"); + } + + const token = localStorage.getItem("token"); + const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const img = new Image(); + img.src = URL.createObjectURL(blob); + + await new Promise((resolve) => { + img.onload = () => resolve(); + }); + + return img; +} + +function getCurrentPageNumber(): number { + // Try Alpine first + const Alpine = (window as any).Alpine; + if (Alpine) { + const readerEl = document.querySelector('[x-data="readerShell"]'); + if (readerEl) { + const readerShell = Alpine.$data(readerEl); + if (readerShell?.currentPage) { + return readerShell.currentPage; + } + } + } + + // Fallback: check for dataset attribute on reader content + const content = document.getElementById("reader-content"); + const pageFromDataset = content?.dataset.currentPage; + if (pageFromDataset) { + return parseInt(pageFromDataset, 10); + } + + // Final fallback + return 1; +} +function loadPage(pageNumber: number): void { + // Dispatch event for reader to handle navigation + window.dispatchEvent( + new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }), + ); +} + +function openPanelEditor(pageNumber: number): void { + const modal = document.getElementById("panel-editor-modal"); + modal?.classList.remove("hidden"); + + // Load page image + const canvas = document.getElementById( + "panel-editor-canvas", + ) as HTMLCanvasElement; + const ctx = canvas?.getContext("2d"); + + // Load image and draw to canvas + loadImageForPage(pageNumber).then((image) => { + canvas!.width = image.width; + canvas!.height = image.height; + ctx?.drawImage(image, 0, 0); + + // Allow user to draw panels + enablePanelDrawing(canvas!); + }); +} + +function enablePanelDrawing(canvas: HTMLCanvasElement): void { + let isDrawing = false; + let startX = 0; + let startY = 0; + + canvas.addEventListener("mousedown", (e) => { + isDrawing = true; + startX = e.offsetX; + startY = e.offsetY; + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isDrawing) return; + + // Draw selection rectangle + const ctx = canvas.getContext("2d"); + ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY); + }); + + canvas.addEventListener("mouseup", (e) => { + if (!isDrawing) return; + isDrawing = false; + + // Save panel + const panel: Panel = { + id: `manual-${Date.now()}`, + x: (startX / canvas.width) * 100, + y: (startY / canvas.height) * 100, + width: ((e.offsetX - startX) / canvas.width) * 100, + height: ((e.offsetY - startY) / canvas.height) * 100, + reading_order: 0, // Will be set by server + }; + + saveManualPanel(panel); + }); +} + +async function saveManualPanel(panel: Panel): Promise { + const mediaItemId = document.body.dataset.mediaItemId; + const pageNumber = getCurrentPageNumber(); + + await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, { + detection_method: "manual", + panels: [panel], + }); + + // Reload with new panels + loadPage(pageNumber); +} + +// Re-detect panels using detection service +async function reDetectPanels(pageNumber: number): Promise { + const image = await loadImageForPage(pageNumber); + + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const result = await detectPanels(imageData, true); + + return result.panels; +} + +// Alpine component +Alpine.data("panelEditor", () => ({ + get isComicOrManga(): boolean { + const libraryType = document.body.dataset.mediaType; + return libraryType === "comic" || libraryType === "manga"; + }, + + openPanelEditor(pageNumber: number) { + openPanelEditor(pageNumber); + }, + + async reDetectPanels(pageNumber: number) { + const panels = await reDetectPanels(pageNumber); + return panels; + }, +})); + +export { openPanelEditor, reDetectPanels }; diff --git a/web/src/reader/comic/panel-gap.ts b/web/src/reader/comic/panel-gap.ts new file mode 100644 index 0000000..3d16b0c --- /dev/null +++ b/web/src/reader/comic/panel-gap.ts @@ -0,0 +1,122 @@ +// Adjustable panel gap controls +// Procedural implementation (no OOP) + +interface PanelGapState { + gapSize: number; + showBorders: boolean; +} + +function createPanelGapState(initialGap: number = 4): PanelGapState { + return { + gapSize: initialGap, + showBorders: false, + }; +} + +function setPanelGap(state: PanelGapState, gap: number): PanelGapState { + const clampedGap = Math.max(0, Math.min(20, gap)); + + document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`); + + return { ...state, gapSize: clampedGap }; +} + +function increasePanelGap( + state: PanelGapState, + amount: number = 2, +): PanelGapState { + return setPanelGap(state, state.gapSize + amount); +} + +function decreasePanelGap( + state: PanelGapState, + amount: number = 2, +): PanelGapState { + return setPanelGap(state, state.gapSize - amount); +} + +function togglePanelBorders(state: PanelGapState): PanelGapState { + const newShowBorders = !state.showBorders; + + document.documentElement.style.setProperty( + "--panel-border-width", + newShowBorders ? "1px" : "0px", + ); + + return { ...state, showBorders: newShowBorders }; +} + +function renderPanelGapControls( + container: HTMLElement, + state: PanelGapState, +): void { + const existing = container.querySelector(".panel-gap-controls"); + existing?.remove(); + + const controls = document.createElement("div"); + controls.className = + "panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40"; + controls.innerHTML = ` + + ${state.gapSize}px + + + `; + + controls + .querySelector(".panel-gap-increase") + ?.addEventListener("click", () => { + const newState = increasePanelGap(state); + updatePanelGapUI(controls, newState); + }); + + controls + .querySelector(".panel-gap-decrease") + ?.addEventListener("click", () => { + const newState = decreasePanelGap(state); + updatePanelGapUI(controls, newState); + }); + + controls + .querySelector(".panel-gap-borders") + ?.addEventListener("click", () => { + const newState = togglePanelBorders(state); + updatePanelGapUI(controls, newState); + }); + + container.appendChild(controls); +} + +function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { + const gapLabel = container.querySelector("span"); + if (gapLabel) { + gapLabel.textContent = `${state.gapSize}px`; + } + + const bordersBtn = container.querySelector(".panel-gap-borders"); + if (bordersBtn) { + bordersBtn.textContent = state.showBorders ? "▦" : "▢"; + } +} + +const panelGapCSS = ` + :root { + --panel-gap: 4px; + --panel-border-width: 0px; + } + + .panel-zoom-container { + gap: var(--panel-gap); + } + + .panel-zoom-container.with-borders { + background: rgba(255, 255, 255, 0.1); + padding: var(--panel-gap); + } + + .panel-borders { + border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3); + } +`; diff --git a/web/src/reader/comic/panel-ml-detector.ts b/web/src/reader/comic/panel-ml-detector.ts new file mode 100644 index 0000000..5b3ac9c --- /dev/null +++ b/web/src/reader/comic/panel-ml-detector.ts @@ -0,0 +1,46 @@ +// 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, + })); +}