feat: show compiled PDF thumbnail in Lumière project cards
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:
claude
2026-06-12 09:20:48 +00:00
parent 88ddbd2513
commit b70c8ddd0e
7 changed files with 162 additions and 1 deletions
+4
View File
@@ -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 }
+6
View File
@@ -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 {