Add comic panel detection system with multi-tier fallback

- 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
This commit is contained in:
2026-04-04 01:00:43 -04:00
parent 169a8e7143
commit c36d8e6b57
12 changed files with 1381 additions and 0 deletions
+103
View File
@@ -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<BackgroundColor, string> = {
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);
});
}
+147
View File
@@ -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 = `
<span class="chapter-number ${isCurrentChapter ? "text-blue-400 font-bold" : ""}">
${chapter.title || `Chapter ${chapter.chapterNumber}`}
</span>
<span class="page-number text-xs">p.${chapter.pageStart}</span>
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ""}
`;
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);
}
`;
+151
View File
@@ -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<number, HTMLImageElement>;
loading: Set<number>;
maxAhead: number;
mediaItemId: string;
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
}
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<PageCacheState & { page: HTMLImageElement }> {
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<PageCacheState & { page: HTMLImageElement }>;
}
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<HTMLImageElement> {
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<any[]> {
// 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 };
+104
View File
@@ -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;
}
+86
View File
@@ -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 = `
<span class="page-label">${state.currentPage}</span>
<input
type="range"
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
min="1"
max="${state.totalPages}"
value="${state.currentPage}"
/>
<span class="page-total">${state.totalPages}</span>
`;
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 } }),
);
}
@@ -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<void> {
if (tfLoaded) return;
// Load TensorFlow.js
await import("@tensorflow/tfjs");
tfLoaded = true;
}
async function loadModel(): Promise<void> {
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<Panel[]> {
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 };
@@ -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<void> {
if (openCVLoaded) return;
// OpenCV.js loads asynchronously and registers globally
await import("@techstark/opencv-js");
// Wait for OpenCV to be ready
return new Promise<void>((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<Panel[]> {
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 };
@@ -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<DetectionResult> {
// 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 };
+172
View File
@@ -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<number>();
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,
};
+167
View File
@@ -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<HTMLImageElement> {
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<void>((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<void> {
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<Panel[]> {
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 };
+122
View File
@@ -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 = `
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
<span class="text-center text-sm">${state.gapSize}px</span>
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
${state.showBorders ? "▦" : "▢"}
</button>
`;
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);
}
`;
+46
View File
@@ -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<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,
}));
}