refactor(previews): update preview variant defaults, cache schema, and image handling
Remove hardcoded defaults for preview variant and width in database schemas to allow more flexible preview generation. Bump preview cache schema version and add versioning to cache rows for consistency. Switch preview image generation to 480px JPEGs with updated file naming and content type. Refactor PDF preview rendering to support configurable output format and quality, and update all preview consumers to use unified "image" buffer property instead of "png". Add migration scripts and update metadata for both Postgres and SQLite. BREAKING CHANGE: Preview variant and width defaults removed from database; preview cache and image handling updated across system.
This commit is contained in:
parent
37a90b734d
commit
1544a2d969
16 changed files with 3634 additions and 143 deletions
2
drizzle/postgres/0006_preview-defaults-cleanup.sql
Normal file
2
drizzle/postgres/0006_preview-defaults-cleanup.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "document_previews" ALTER COLUMN "variant" DROP DEFAULT;--> statement-breakpoint
|
||||
ALTER TABLE "document_previews" ALTER COLUMN "width" DROP DEFAULT;
|
||||
1831
drizzle/postgres/meta/0006_snapshot.json
Normal file
1831
drizzle/postgres/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
|||
"when": 1779041718214,
|
||||
"tag": "0005_pdf_layout_and_compute",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1779102950715,
|
||||
"tag": "0006_preview-defaults-cleanup",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
27
drizzle/sqlite/0006_preview-defaults-cleanup.sql
Normal file
27
drizzle/sqlite/0006_preview-defaults-cleanup.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_document_previews` (
|
||||
`document_id` text NOT NULL,
|
||||
`namespace` text DEFAULT '' NOT NULL,
|
||||
`variant` text NOT NULL,
|
||||
`status` text DEFAULT 'queued' NOT NULL,
|
||||
`source_last_modified_ms` integer NOT NULL,
|
||||
`object_key` text NOT NULL,
|
||||
`content_type` text DEFAULT 'image/jpeg' NOT NULL,
|
||||
`width` integer NOT NULL,
|
||||
`height` integer,
|
||||
`byte_size` integer,
|
||||
`etag` text,
|
||||
`lease_owner` text,
|
||||
`lease_until_ms` integer DEFAULT 0 NOT NULL,
|
||||
`attempt_count` integer DEFAULT 0 NOT NULL,
|
||||
`last_error` text,
|
||||
`created_at_ms` integer DEFAULT 0 NOT NULL,
|
||||
`updated_at_ms` integer DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY(`document_id`, `namespace`, `variant`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_document_previews`("document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms") SELECT "document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms" FROM `document_previews`;--> statement-breakpoint
|
||||
DROP TABLE `document_previews`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_document_previews` RENAME TO `document_previews`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `idx_document_previews_status_lease` ON `document_previews` (`status`,`lease_until_ms`);
|
||||
1693
drizzle/sqlite/meta/0006_snapshot.json
Normal file
1693
drizzle/sqlite/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
|||
"when": 1779041717890,
|
||||
"tag": "0005_pdf_layout_and_compute",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1779102950385,
|
||||
"tag": "0006_preview-defaults-cleanup",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -103,12 +103,12 @@ export const userDocumentProgress = pgTable('user_document_progress', {
|
|||
export const documentPreviews = pgTable('document_previews', {
|
||||
documentId: text('document_id').notNull(),
|
||||
namespace: text('namespace').notNull().default(''),
|
||||
variant: text('variant').notNull().default('card-240-jpeg'),
|
||||
variant: text('variant').notNull(),
|
||||
status: text('status').notNull().default('queued'),
|
||||
sourceLastModifiedMs: bigint('source_last_modified_ms', { mode: 'number' }).notNull(),
|
||||
objectKey: text('object_key').notNull(),
|
||||
contentType: text('content_type').notNull().default('image/jpeg'),
|
||||
width: integer('width').notNull().default(240),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height'),
|
||||
byteSize: bigint('byte_size', { mode: 'number' }),
|
||||
eTag: text('etag'),
|
||||
|
|
|
|||
|
|
@ -103,12 +103,12 @@ export const userDocumentProgress = sqliteTable('user_document_progress', {
|
|||
export const documentPreviews = sqliteTable('document_previews', {
|
||||
documentId: text('document_id').notNull(),
|
||||
namespace: text('namespace').notNull().default(''),
|
||||
variant: text('variant').notNull().default('card-240-jpeg'),
|
||||
variant: text('variant').notNull(),
|
||||
status: text('status').notNull().default('queued'),
|
||||
sourceLastModifiedMs: integer('source_last_modified_ms').notNull(),
|
||||
objectKey: text('object_key').notNull(),
|
||||
contentType: text('content_type').notNull().default('image/jpeg'),
|
||||
width: integer('width').notNull().default(240),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height'),
|
||||
byteSize: integer('byte_size'),
|
||||
eTag: text('etag'),
|
||||
|
|
|
|||
7
src/lib/client/cache/previews.ts
vendored
7
src/lib/client/cache/previews.ts
vendored
|
|
@ -8,6 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli
|
|||
|
||||
const inMemoryPreviewUrlCache = new Map<string, string>();
|
||||
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
|
||||
const PREVIEW_CACHE_SCHEMA_VERSION = 3;
|
||||
|
||||
function revokeIfBlobUrl(url: string | null | undefined): void {
|
||||
if (!url) return;
|
||||
|
|
@ -46,6 +47,11 @@ export async function getPersistedDocumentPreviewUrl(
|
|||
const row = await getDocumentPreviewCache(docId);
|
||||
if (!row) return null;
|
||||
|
||||
if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) {
|
||||
await removeDocumentPreviewCache(docId).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Number(row.lastModified) !== Number(lastModified)) {
|
||||
await removeDocumentPreviewCache(docId).catch(() => {});
|
||||
return null;
|
||||
|
|
@ -102,6 +108,7 @@ export async function primeDocumentPreviewCache(
|
|||
contentType,
|
||||
data: bytes,
|
||||
cachedAt: Date.now(),
|
||||
previewVersion: PREVIEW_CACHE_SCHEMA_VERSION,
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export interface DocumentPreviewCacheRow {
|
|||
data: ArrayBuffer;
|
||||
cachedAt: number;
|
||||
byteSize?: number;
|
||||
previewVersion?: number;
|
||||
}
|
||||
|
||||
export interface ConfigRow {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage
|
|||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
const DEFAULT_NAMESPACE_SEGMENT = '_default';
|
||||
|
||||
export const DOCUMENT_PREVIEW_VARIANT = 'card-240-jpeg';
|
||||
export const DOCUMENT_PREVIEW_FILE_NAME = 'card-240.jpg';
|
||||
export const DOCUMENT_PREVIEW_VARIANT = 'card-480-jpeg';
|
||||
export const DOCUMENT_PREVIEW_FILE_NAME = 'card-480.jpg';
|
||||
export const DOCUMENT_PREVIEW_CONTENT_TYPE = 'image/jpeg';
|
||||
export const DOCUMENT_PREVIEW_WIDTH = 240;
|
||||
export const DOCUMENT_PREVIEW_WIDTH = 480;
|
||||
|
||||
function sanitizeNamespace(namespace: string | null): string | null {
|
||||
if (!namespace) return null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import path from 'path';
|
||||
import { DOMMatrix, Path2D, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import JSZip from 'jszip';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { renderPage } from '@/lib/server/pdf-layout/renderPage';
|
||||
|
||||
export type RenderedDocumentPreview = {
|
||||
bytes: Buffer;
|
||||
|
|
@ -9,26 +10,7 @@ export type RenderedDocumentPreview = {
|
|||
height: number;
|
||||
};
|
||||
|
||||
type CanvasAndContext = {
|
||||
canvas: unknown;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type NodeCanvasFactory = {
|
||||
create: (width: number, height: number) => CanvasAndContext;
|
||||
reset: (target: CanvasAndContext, width: number, height: number) => void;
|
||||
destroy: (target: CanvasAndContext) => void;
|
||||
};
|
||||
|
||||
function ensureNodeCanvasGlobals(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
if (typeof g.DOMMatrix === 'undefined') {
|
||||
g.DOMMatrix = DOMMatrix as unknown;
|
||||
}
|
||||
if (typeof g.Path2D === 'undefined') {
|
||||
g.Path2D = Path2D as unknown;
|
||||
}
|
||||
}
|
||||
const PREVIEW_JPEG_QUALITY = 82;
|
||||
|
||||
function normalizeTargetWidth(targetWidth: number): number {
|
||||
if (!Number.isFinite(targetWidth) || targetWidth <= 0) return 240;
|
||||
|
|
@ -99,9 +81,11 @@ async function renderImageBytesToJpeg(imageBytes: Buffer, targetWidth: number):
|
|||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, outWidth, outHeight);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bitmap, 0, 0, outWidth, outHeight);
|
||||
return {
|
||||
bytes: canvas.toBuffer('image/jpeg', 82),
|
||||
bytes: canvas.toBuffer('image/jpeg', PREVIEW_JPEG_QUALITY),
|
||||
width: outWidth,
|
||||
height: outHeight,
|
||||
};
|
||||
|
|
@ -150,109 +134,18 @@ export async function renderEpubCoverToJpeg(sourceBytes: Buffer, targetWidth: nu
|
|||
}
|
||||
|
||||
export async function renderPdfFirstPageToJpeg(sourceBytes: Buffer, targetWidth: number): Promise<RenderedDocumentPreview> {
|
||||
ensureNodeCanvasGlobals();
|
||||
|
||||
type PdfViewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
type PdfPage = {
|
||||
getViewport: (params: { scale: number }) => PdfViewport;
|
||||
render: (params: {
|
||||
canvasContext: unknown;
|
||||
viewport: PdfViewport;
|
||||
intent: 'display';
|
||||
canvasFactory?: NodeCanvasFactory;
|
||||
}) => { promise: Promise<void> };
|
||||
};
|
||||
|
||||
// pdfjs-dist legacy build works in Node and avoids relying on DOM workers.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - pdfjs-dist legacy build path has no dedicated TypeScript declaration.
|
||||
const pdfjs = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as {
|
||||
getDocument: (options: Record<string, unknown>) => {
|
||||
promise: Promise<{ getPage: (n: number) => Promise<PdfPage>; destroy: () => Promise<void> }>;
|
||||
destroy: () => Promise<void>;
|
||||
};
|
||||
GlobalWorkerOptions?: { workerSrc?: string; workerPort?: unknown };
|
||||
};
|
||||
|
||||
if (pdfjs.GlobalWorkerOptions) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||
pdfjs.GlobalWorkerOptions.workerPort = null;
|
||||
}
|
||||
|
||||
const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts');
|
||||
const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`;
|
||||
|
||||
const nodeCanvasFactory: NodeCanvasFactory = {
|
||||
create: (width, height) => {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
return { canvas, context };
|
||||
},
|
||||
reset: (target, width, height) => {
|
||||
const canvas = target.canvas as { width: number; height: number };
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
},
|
||||
destroy: (target) => {
|
||||
const canvas = target.canvas as { width: number; height: number };
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
},
|
||||
};
|
||||
|
||||
class PdfNodeCanvasFactory {
|
||||
create(width: number, height: number): CanvasAndContext {
|
||||
return nodeCanvasFactory.create(width, height);
|
||||
}
|
||||
reset(target: CanvasAndContext, width: number, height: number): void {
|
||||
nodeCanvasFactory.reset(target, width, height);
|
||||
}
|
||||
destroy(target: CanvasAndContext): void {
|
||||
nodeCanvasFactory.destroy(target);
|
||||
}
|
||||
}
|
||||
|
||||
const loadingTask = pdfjs.getDocument({
|
||||
data: new Uint8Array(sourceBytes),
|
||||
useWorkerFetch: false,
|
||||
standardFontDataUrl,
|
||||
CanvasFactory: PdfNodeCanvasFactory,
|
||||
isEvalSupported: false,
|
||||
const width = normalizeTargetWidth(targetWidth);
|
||||
const isolatedBytes = Uint8Array.from(sourceBytes);
|
||||
const rendered = await renderPage({
|
||||
pdfBytes: isolatedBytes.buffer as ArrayBuffer,
|
||||
pageNumber: 1,
|
||||
targetWidth: width,
|
||||
format: 'jpeg',
|
||||
jpegQuality: PREVIEW_JPEG_QUALITY,
|
||||
});
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const target = normalizeTargetWidth(targetWidth);
|
||||
const scale = target / viewport.width;
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
|
||||
const outWidth = Math.max(1, Math.floor(scaledViewport.width));
|
||||
const outHeight = Math.max(1, Math.floor(scaledViewport.height));
|
||||
const canvas = createCanvas(outWidth, outHeight);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, outWidth, outHeight);
|
||||
|
||||
const renderTask = page.render({
|
||||
canvasContext: ctx,
|
||||
viewport: scaledViewport,
|
||||
intent: 'display',
|
||||
canvasFactory: nodeCanvasFactory,
|
||||
});
|
||||
await renderTask.promise;
|
||||
|
||||
return {
|
||||
bytes: canvas.toBuffer('image/jpeg', 82),
|
||||
width: outWidth,
|
||||
height: outHeight,
|
||||
};
|
||||
} finally {
|
||||
await pdf.destroy().catch(() => undefined);
|
||||
await loadingTask.destroy().catch(() => undefined);
|
||||
}
|
||||
return {
|
||||
bytes: rendered.image,
|
||||
width: rendered.width,
|
||||
height: rendered.height,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,6 +306,9 @@ async function generateAndStorePreview(doc: PreviewSourceDocument, namespace: st
|
|||
throw new Error(`Unsupported preview type: ${doc.type}`);
|
||||
}
|
||||
|
||||
// Replace-in-place semantics: clear old preview objects for this document
|
||||
// prefix before writing the current variant so stale files don't linger.
|
||||
await deleteDocumentPreviewArtifacts(doc.id, namespace).catch(() => undefined);
|
||||
await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace);
|
||||
} finally {
|
||||
if (workDir) {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
|
|||
pageWidth: rendered.width,
|
||||
pageHeight: rendered.height,
|
||||
textItems: layoutTextItems,
|
||||
pagePng: rendered.png,
|
||||
pageImage: rendered.image,
|
||||
});
|
||||
const merged = mergeTextWithRegions(regions, layoutTextItems);
|
||||
if (textItems.length > 0 && merged.length === 0) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ interface RenderInput {
|
|||
pdfBytes: ArrayBuffer;
|
||||
pageNumber: number;
|
||||
scale?: number;
|
||||
targetWidth?: number;
|
||||
format?: 'png' | 'jpeg';
|
||||
jpegQuality?: number;
|
||||
}
|
||||
|
||||
function createPdfjsCanvasFactory(runtime: CanvasRuntime) {
|
||||
|
|
@ -81,10 +84,18 @@ function createPdfjsCanvasFactory(runtime: CanvasRuntime) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderInput): Promise<{
|
||||
export async function renderPage({
|
||||
pdfBytes,
|
||||
pageNumber,
|
||||
scale = 1.5,
|
||||
targetWidth,
|
||||
format = 'png',
|
||||
jpegQuality = 82,
|
||||
}: RenderInput): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
png: Buffer;
|
||||
image: Buffer;
|
||||
contentType: 'image/png' | 'image/jpeg';
|
||||
}> {
|
||||
// pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so
|
||||
// callers can safely reuse their original bytes across pages/calls.
|
||||
|
|
@ -116,7 +127,11 @@ export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderIn
|
|||
const pdf = await loadingTask.promise;
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const baseViewport = page.getViewport({ scale: 1.0 });
|
||||
const effectiveScale = typeof targetWidth === 'number' && Number.isFinite(targetWidth) && targetWidth > 0
|
||||
? (Math.max(1, Math.round(targetWidth)) / Math.max(1, baseViewport.width))
|
||||
: scale;
|
||||
const viewport = page.getViewport({ scale: effectiveScale });
|
||||
const width = Math.max(1, Math.floor(viewport.width));
|
||||
const height = Math.max(1, Math.floor(viewport.height));
|
||||
const canvas = canvasRuntime.createCanvasFn(width, height);
|
||||
|
|
@ -130,10 +145,15 @@ export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderIn
|
|||
intent: 'display',
|
||||
});
|
||||
await renderTask.promise;
|
||||
const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||
const image = format === 'jpeg'
|
||||
? canvas.toBuffer('image/jpeg', jpegQuality)
|
||||
: canvas.toBuffer('image/png');
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
png: canvas.toBuffer('image/png'),
|
||||
image,
|
||||
contentType,
|
||||
};
|
||||
} finally {
|
||||
await pdf.destroy().catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ interface RunLayoutInput {
|
|||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
textItems: PdfTextItem[];
|
||||
pagePng: Buffer;
|
||||
pageImage: Buffer;
|
||||
}
|
||||
|
||||
const DEFAULT_INPUT_SIZE = 800;
|
||||
|
|
@ -255,9 +255,9 @@ function normalizeModelLabel(rawLabel: string): string {
|
|||
}
|
||||
|
||||
export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegion[]> {
|
||||
const { pageWidth, pageHeight, textItems, pagePng } = input;
|
||||
const { pageWidth, pageHeight, textItems, pageImage } = input;
|
||||
if (!textItems.length) return [];
|
||||
if (!pagePng || pagePng.byteLength === 0) {
|
||||
if (!pageImage || pageImage.byteLength === 0) {
|
||||
throw new Error('layout-render-missing-page-image');
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +269,8 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
|
|||
getCanvasFns(),
|
||||
]);
|
||||
|
||||
const pageImage = await canvasFns.loadImageFn(pagePng);
|
||||
const pixelValues = preprocessResized(pageImage, preprocessor, canvasFns.createCanvasFn);
|
||||
const decodedPageImage = await canvasFns.loadImageFn(pageImage);
|
||||
const pixelValues = preprocessResized(decodedPageImage, preprocessor, canvasFns.createCanvasFn);
|
||||
const output = await session.run({ pixel_values: pixelValues });
|
||||
|
||||
const logits = output.logits?.data as Float32Array | undefined;
|
||||
|
|
|
|||
Loading…
Reference in a new issue