From b70c8ddd0e7de35c662ccc454a4236d13808f81e Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 12 Jun 2026 09:20:48 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20show=20compiled=20PDF=20thumbnail=20in?= =?UTF-8?q?=20Lumi=C3=A8re=20project=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/clsi/app.js | 4 ++ services/clsi/app/js/ConversionController.js | 50 +++++++++++++++ .../Features/Compile/CompileController.mjs | 15 +++++ .../src/Features/Compile/ThumbnailManager.mjs | 63 +++++++++++++++++++ services/web/app/src/router.mjs | 6 ++ .../components/project-list-lumiere.tsx | 9 +++ .../pages/project-list-lumiere.scss | 16 ++++- 7 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 services/web/app/src/Features/Compile/ThumbnailManager.mjs diff --git a/services/clsi/app.js b/services/clsi/app.js index 231e72dd86..286aa4d120 100644 --- a/services/clsi/app.js +++ b/services/clsi/app.js @@ -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) => { diff --git a/services/clsi/app/js/ConversionController.js b/services/clsi/app/js/ConversionController.js index 1e62cd9873..e208498b63 100644 --- a/services/clsi/app/js/ConversionController.js +++ b/services/clsi/app/js/ConversionController.js @@ -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), } diff --git a/services/web/app/src/Features/Compile/CompileController.mjs b/services/web/app/src/Features/Compile/CompileController.mjs index aed625f2b9..4ae2647cea 100644 --- a/services/web/app/src/Features/Compile/CompileController.mjs +++ b/services/web/app/src/Features/Compile/CompileController.mjs @@ -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, diff --git a/services/web/app/src/Features/Compile/ThumbnailManager.mjs b/services/web/app/src/Features/Compile/ThumbnailManager.mjs new file mode 100644 index 0000000000..2df4c6e491 --- /dev/null +++ b/services/web/app/src/Features/Compile/ThumbnailManager.mjs @@ -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 } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index c67ab996cb..62b4a4b510 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -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, diff --git a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx index 60dcc66ecd..0329f91bfe 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx @@ -74,6 +74,15 @@ const ProjectCard = memo(function ProjectCard({ translate="no" >
+ {/* eslint-disable-next-line jsx-a11y/alt-text */} + { + e.currentTarget.style.display = 'none' + }} + /> {initial}
diff --git a/services/web/frontend/stylesheets/pages/project-list-lumiere.scss b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss index 90169d64cc..dae9a322f8 100644 --- a/services/web/frontend/stylesheets/pages/project-list-lumiere.scss +++ b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss @@ -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 {