feat: show compiled PDF thumbnail in Lumière project cards
Build and Deploy Verso / deploy (push) Successful in 14m47s
Build and Deploy Verso / deploy (push) Successful in 14m47s
After a successful compile, web service calls a new CLSI endpoint (GET /project/:id/user/:uid/build/:bid/thumbnail) which runs pdftocairo page-1 to a 190px-wide JPEG using the existing thumbnail preset. The JPEG is stored in Redis (90-day TTL, overwritten on next compile) by the new ThumbnailManager. GET /project/:Project_id/thumbnail serves the cached JPEG to authenticated users, returning 404 when no thumbnail exists. Project cards in the Lumière grid show the image overlaying the coloured gradient tile; if the image 404s (project never compiled or cache expired) the onerror handler hides it and the gradient + initial letter shows through. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,10 @@ app.post(
|
||||
FileUploadMiddleware.multerMiddleware,
|
||||
ConversionController.convertPDFToJPEG
|
||||
)
|
||||
app.get(
|
||||
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
|
||||
ConversionController.thumbnailFromBuild
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
||||
app.get('/coverage', (req, res) => {
|
||||
|
||||
@@ -251,8 +251,58 @@ async function convertProjectToDocument(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function thumbnailFromBuild(req, res) {
|
||||
if (!Settings.enablePdfConversions) return res.sendStatus(404)
|
||||
|
||||
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
|
||||
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
|
||||
|
||||
const compileName = userId ? `${projectId}-${userId}` : projectId
|
||||
const pdfPath = Path.join(
|
||||
Settings.path.outputDir,
|
||||
compileName,
|
||||
OutputCacheManager.CACHE_SUBDIR,
|
||||
buildId,
|
||||
'output.pdf'
|
||||
)
|
||||
|
||||
try {
|
||||
await fs.access(pdfPath)
|
||||
} catch {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const conversionId = crypto.randomUUID()
|
||||
let jpegPath
|
||||
try {
|
||||
jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock(
|
||||
conversionId,
|
||||
pdfPath,
|
||||
'thumbnail'
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn({ err, projectId, buildId }, 'thumbnail conversion failed')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
try {
|
||||
const jpegStat = await fs.stat(jpegPath)
|
||||
res.setHeader('Content-Type', 'image/jpeg')
|
||||
res.setHeader('Content-Length', jpegStat.size)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
const readStream = fsSync.createReadStream(jpegPath)
|
||||
await pipeline(readStream, res)
|
||||
} finally {
|
||||
await fs
|
||||
.rm(Path.dirname(jpegPath), { recursive: true, force: true })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
||||
thumbnailFromBuild: expressify(thumbnailFromBuild),
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
fetchStreamWithResponse,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
import ThumbnailManager from './ThumbnailManager.mjs'
|
||||
import Features from '../../infrastructure/Features.mjs'
|
||||
import ClsiCacheController from './ClsiCacheController.mjs'
|
||||
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
||||
@@ -321,6 +322,10 @@ const _CompileController = {
|
||||
.catch(err =>
|
||||
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
|
||||
)
|
||||
|
||||
ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch(
|
||||
err => logger.warn({ err, projectId }, 'thumbnail cache error')
|
||||
)
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -346,6 +351,15 @@ const _CompileController = {
|
||||
res.sendStatus(200)
|
||||
},
|
||||
|
||||
async getProjectThumbnail(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const jpeg = await ThumbnailManager.getCachedThumbnail(projectId)
|
||||
if (!jpeg) return res.sendStatus(404)
|
||||
res.setHeader('Content-Type', 'image/jpeg')
|
||||
res.setHeader('Cache-Control', 'public, max-age=60')
|
||||
res.send(jpeg)
|
||||
},
|
||||
|
||||
// Used for submissions through the public API
|
||||
async compileSubmission(req, res) {
|
||||
res.setTimeout(COMPILE_TIMEOUT_MS)
|
||||
@@ -822,6 +836,7 @@ const CompileController = {
|
||||
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
|
||||
proxySyncCode: expressify(_CompileController.proxySyncCode),
|
||||
wordCount: expressify(_CompileController.wordCount),
|
||||
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
|
||||
|
||||
_getSafeProjectName: _CompileController._getSafeProjectName,
|
||||
_getSplitTestOptions,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
|
||||
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
|
||||
|
||||
const rclient = RedisWrapper.client('web')
|
||||
const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days
|
||||
|
||||
function redisKey(projectId) {
|
||||
return `Thumbnail:${projectId}`
|
||||
}
|
||||
|
||||
function clsiThumbnailUrl(projectId, userId, buildId) {
|
||||
return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail`
|
||||
}
|
||||
|
||||
async function generateAndCacheThumbnail(projectId, userId, buildId) {
|
||||
if (!userId || !buildId) return
|
||||
|
||||
const url = clsiThumbnailUrl(projectId, userId, buildId)
|
||||
let stream
|
||||
try {
|
||||
;({ stream } = await fetchStreamWithResponse(url))
|
||||
} catch (err) {
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
logger.warn(
|
||||
{ err, projectId, buildId },
|
||||
'unexpected error fetching thumbnail from CLSI'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
const jpegBuffer = Buffer.concat(chunks)
|
||||
if (jpegBuffer.length === 0) return
|
||||
await rclient.set(
|
||||
redisKey(projectId),
|
||||
jpegBuffer.toString('base64'),
|
||||
'EX',
|
||||
THUMBNAIL_TTL
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis')
|
||||
}
|
||||
}
|
||||
|
||||
async function getCachedThumbnail(projectId) {
|
||||
try {
|
||||
const b64 = await rclient.get(redisKey(projectId))
|
||||
if (!b64) return null
|
||||
return Buffer.from(b64, 'base64')
|
||||
} catch (err) {
|
||||
logger.warn({ err, projectId }, 'failed to get thumbnail from Redis')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default { generateAndCacheThumbnail, getCachedThumbnail }
|
||||
@@ -611,6 +611,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
CompileController.stopCompile
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/thumbnail',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
CompileController.getProjectThumbnail
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/output/cached/output.overleaf.json',
|
||||
AsyncLocalStorage.middleware,
|
||||
|
||||
@@ -74,6 +74,15 @@ const ProjectCard = memo(function ProjectCard({
|
||||
translate="no"
|
||||
>
|
||||
<div className="lumiere-card-thumb">
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img
|
||||
className="lumiere-card-thumb-img"
|
||||
src={`/project/${project.id}/thumbnail`}
|
||||
aria-hidden="true"
|
||||
onError={e => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
<span className="lumiere-card-initial">{initial}</span>
|
||||
</div>
|
||||
<div className="lumiere-card-body">
|
||||
|
||||
@@ -534,10 +534,22 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
|
||||
border-style: solid;
|
||||
border-width: 0 28px 28px 0;
|
||||
border-color: transparent rgba(255, 255, 255, 0.3) transparent transparent;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail image — shown when the project has a cached compiled PDF page.
|
||||
// Falls back to the gradient + initial when the image 404s or is loading.
|
||||
.lumiere-card-thumb-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lumiere-card-initial {
|
||||
font-family: Georgia, 'Times New Roman', 'DejaVu Serif', serif;
|
||||
font-size: 3rem;
|
||||
@@ -545,6 +557,8 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.lumiere-card--latex .lumiere-card-thumb {
|
||||
|
||||
Reference in New Issue
Block a user