Fix PDF thumbnails (ghostscript), 3MF viewer (official loader), PDF viewer (blob fetch)
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m9s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m9s
This commit is contained in:
parent
facb1c6f8b
commit
c239fbb296
5 changed files with 218 additions and 51 deletions
|
|
@ -21,6 +21,7 @@ RUN apk add --no-cache \
|
|||
ffmpeg \
|
||||
exiftool \
|
||||
sqlite \
|
||||
ghostscript \
|
||||
ca-certificates \
|
||||
mailcap \
|
||||
tzdata
|
||||
|
|
|
|||
|
|
@ -148,7 +148,9 @@ func (m *ThumbnailManager) worker(id int) {
|
|||
err = generateVideoThumbnail(fullSourcePath, destPath)
|
||||
} else if strings.HasPrefix(job.MimeType, "audio/") {
|
||||
err = generateAudioThumbnail(fullSourcePath, destPath)
|
||||
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") || strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
|
||||
} else if strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") {
|
||||
err = generatePdfThumbnail(fullSourcePath, destPath)
|
||||
} else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") {
|
||||
err = generateOfficeThumbnail(job.FilePath, destPath, m.Config)
|
||||
} else {
|
||||
continue
|
||||
|
|
@ -211,6 +213,39 @@ func generateAudioThumbnail(src, dest string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func generatePdfThumbnail(src, dest string) error {
|
||||
// Use Ghostscript to render the first page of the PDF as a JPEG
|
||||
tmpFile := dest + ".tmp.jpg"
|
||||
cmd := exec.Command("gs",
|
||||
"-dNOPAUSE", "-dBATCH", "-dSAFER", "-dQUIET",
|
||||
"-sDEVICE=jpeg",
|
||||
"-dFirstPage=1", "-dLastPage=1",
|
||||
"-r72", // 72 DPI is enough for a thumbnail
|
||||
"-dJPEGQ=85",
|
||||
fmt.Sprintf("-sOutputFile=%s", tmpFile),
|
||||
src,
|
||||
)
|
||||
|
||||
timer := time.AfterFunc(30*time.Second, func() {
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
err := cmd.Run()
|
||||
timer.Stop()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tmpFile)
|
||||
return fmt.Errorf("ghostscript failed: %w", err)
|
||||
}
|
||||
|
||||
// Resize the rendered page to standard thumbnail size
|
||||
err = generateImageThumbnail(tmpFile, dest)
|
||||
os.Remove(tmpFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error {
|
||||
// Generate a download token for the Conversion API to fetch the file from our backend
|
||||
token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret, filePath)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const nextConfig = {
|
|||
serverActions: {
|
||||
bodySizeLimit: '100gb',
|
||||
},
|
||||
proxyTimeout: 300000, // 5 minutes for large file proxying
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
|
@ -11,24 +11,44 @@ if (typeof window !== 'undefined') {
|
|||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
}
|
||||
|
||||
function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number }) {
|
||||
function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; scale: number; containerWidth: number }) {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: false,
|
||||
rootMargin: '200% 0px', // load pages 2 screens ahead/behind
|
||||
rootMargin: '150% 0px',
|
||||
});
|
||||
|
||||
// Estimate the page height based on typical A4 ratio (1:1.414)
|
||||
const estimatedWidth = Math.min(containerWidth * 0.9, 900) * scale;
|
||||
const estimatedHeight = estimatedWidth * 1.414;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="min-h-[800px] flex justify-center mb-6 w-full" style={{ minHeight: `${800 * scale}px` }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex justify-center w-full mb-4"
|
||||
style={{ minHeight: inView ? undefined : `${estimatedHeight}px` }}
|
||||
>
|
||||
{inView ? (
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
width={Math.min(containerWidth * 0.9, 900)}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="bg-white shadow-xl"
|
||||
loading={
|
||||
<div
|
||||
className="bg-white shadow-xl flex items-center justify-center"
|
||||
style={{ width: `${estimatedWidth}px`, height: `${estimatedHeight}px` }}
|
||||
>
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white shadow-xl w-[600px]" style={{ width: `${600 * scale}px` }} />
|
||||
<div
|
||||
className="bg-gray-100 shadow-xl rounded"
|
||||
style={{ width: `${estimatedWidth}px`, height: `${estimatedHeight}px` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -36,24 +56,96 @@ function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number
|
|||
|
||||
export default function PdfViewer({ url }: { url: string }) {
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
const [scale, setScale] = useState<number>(1.2);
|
||||
const [scale, setScale] = useState<number>(1.0);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [fetchingPdf, setFetchingPdf] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
|
||||
// Measure container width for responsive page sizing
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Fetch the PDF as a blob to avoid Next.js proxy streaming issues with large files
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFetchingPdf(true);
|
||||
setFetchError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setFetchError(`Failed to load PDF: ${err.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setFetchingPdf(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||
setNumPages(numPages);
|
||||
}
|
||||
|
||||
if (fetchingPdf) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Loading PDF...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-danger, #ef4444)' }}>{fetchError}</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-4 py-2 mt-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
||||
>
|
||||
Download Instead
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{/* PDF Toolbar */}
|
||||
<div className="flex items-center justify-between p-2 border-b" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-elevated)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium min-w-[80px] text-center" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{numPages ? `${numPages} Pages` : 'Loading...'}
|
||||
{numPages ? `${numPages} page${numPages !== 1 ? 's' : ''}` : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setScale(s => Math.max(0.5, s - 0.25))}
|
||||
onClick={() => setScale(s => Math.max(0.5, +(s - 0.25).toFixed(2)))}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
|
|
@ -63,7 +155,7 @@ export default function PdfViewer({ url }: { url: string }) {
|
|||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale(s => Math.min(3, s + 0.25))}
|
||||
onClick={() => setScale(s => Math.min(3, +(s + 0.25).toFixed(2)))}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
|
|
@ -73,9 +165,9 @@ export default function PdfViewer({ url }: { url: string }) {
|
|||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900 py-4 flex flex-col items-center">
|
||||
<div ref={containerRef} className="flex-1 overflow-auto bg-neutral-100 dark:bg-neutral-900 py-4 flex flex-col items-center">
|
||||
<Document
|
||||
file={url}
|
||||
file={blobUrl!}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={(error) => console.error('Failed to load PDF:', error)}
|
||||
loading={
|
||||
|
|
@ -85,8 +177,13 @@ export default function PdfViewer({ url }: { url: string }) {
|
|||
}
|
||||
className="flex flex-col items-center gap-2 w-full"
|
||||
>
|
||||
{numPages && Array.from(new Array(numPages), (el, index) => (
|
||||
<VirtualPage key={`page_${index + 1}`} pageNumber={index + 1} scale={scale} />
|
||||
{numPages && Array.from(new Array(numPages), (_, index) => (
|
||||
<LazyPage
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
scale={scale}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Center } from '@react-three/drei';
|
||||
import { ThreeMFLoader } from 'three-stdlib';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Canvas, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
|
|
@ -11,29 +10,64 @@ interface ThreeMfPreviewProps {
|
|||
|
||||
function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onError: (e: any) => void }) {
|
||||
const [model, setModel] = useState<THREE.Group | null>(null);
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const loader = new ThreeMFLoader();
|
||||
loader.load(
|
||||
url,
|
||||
(group) => {
|
||||
// Adjust materials for shadow casting
|
||||
group.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
let cancelled = false;
|
||||
|
||||
// Dynamically import the official Three.js 3MFLoader
|
||||
import('three/examples/jsm/loaders/3MFLoader.js').then(({ ThreeMFLoader }) => {
|
||||
if (cancelled) return;
|
||||
const loader = new ThreeMFLoader();
|
||||
|
||||
loader.load(
|
||||
url,
|
||||
(group: THREE.Group) => {
|
||||
if (cancelled) return;
|
||||
|
||||
// Ensure all meshes have materials and shadows
|
||||
group.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
// Ensure material exists
|
||||
if (!mesh.material) {
|
||||
mesh.material = new THREE.MeshStandardMaterial({ color: 0x888888 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Center and fit model to camera
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
group.position.sub(center);
|
||||
|
||||
// Adjust camera to fit
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (maxDim > 0) {
|
||||
const fov = (camera as THREE.PerspectiveCamera).fov * (Math.PI / 180);
|
||||
const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.5;
|
||||
camera.position.set(dist * 0.7, dist * 0.5, dist);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
});
|
||||
setModel(group);
|
||||
onLoad();
|
||||
},
|
||||
undefined,
|
||||
(err) => {
|
||||
onError(err);
|
||||
}
|
||||
);
|
||||
}, [url, onLoad, onError]);
|
||||
|
||||
setModel(group);
|
||||
onLoad();
|
||||
},
|
||||
undefined,
|
||||
(err: any) => {
|
||||
if (cancelled) return;
|
||||
onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
);
|
||||
}).catch((err) => {
|
||||
if (!cancelled) onError(err);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!model) return null;
|
||||
return <primitive object={model} />;
|
||||
|
|
@ -64,21 +98,20 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
|
|||
|
||||
<div className={`w-full h-full transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<Canvas shadows dpr={[1, 2]} camera={{ position: [0, 0, 150], fov: 50 }}>
|
||||
<ambientLight intensity={0.5} />
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[10, 10, 10]} intensity={1} castShadow />
|
||||
<directionalLight position={[-10, -10, -10]} intensity={0.2} />
|
||||
<directionalLight position={[-10, -5, -10]} intensity={0.3} />
|
||||
<hemisphereLight intensity={0.3} />
|
||||
<React.Suspense fallback={null}>
|
||||
<Center>
|
||||
<Model
|
||||
url={url}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={(err) => {
|
||||
console.error('3MF Load Error:', err);
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
<Model
|
||||
url={url}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={(err) => {
|
||||
console.error('3MF Load Error:', err);
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<OrbitControls makeDefault />
|
||||
</Canvas>
|
||||
|
|
|
|||
Reference in a new issue