From c5883e5954b3e15e0e6f51e0bc384cfc860fdfc3 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 13 Jun 2026 16:02:54 +0000 Subject: [PATCH] feat: generate first-slide thumbnail for Quarto RevealJS presentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thumbnailFromBuild() now tries output.pdf → output-slides.pdf → decktape on output.html (slide 1 only). The web service's ThumbnailManager already calls this endpoint fire-and-forget on every successful compile, so RevealJS project cards will show the first slide thumbnail automatically. Co-Authored-By: Claude Sonnet 4.6 --- services/clsi/app/js/ConversionController.js | 70 +++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/services/clsi/app/js/ConversionController.js b/services/clsi/app/js/ConversionController.js index 8f73746a3c..5790c2764c 100644 --- a/services/clsi/app/js/ConversionController.js +++ b/services/clsi/app/js/ConversionController.js @@ -256,26 +256,73 @@ async function convertProjectToDocument(req, res) { } } -// Generates a JPEG thumbnail of page 1 of the compiled output.pdf using -// pdftocairo (poppler-utils, installed in the CLSI base image). Does NOT -// require ENABLE_PDF_CONVERSIONS or the pdftocairo Docker image. +// Generates a JPEG thumbnail of page 1 of the compiled output using +// pdftocairo (poppler-utils). Tries output.pdf first (LaTeX / Quarto-PDF), +// then output-slides.pdf (Quarto RevealJS after a PDF-export compile), then +// falls back to rendering slide 1 of output.html via decktape (normal +// RevealJS preview compile). All temp dirs are cleaned up in finally. async function thumbnailFromBuild(req, res) { 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( + const buildDir = Path.join( Settings.path.outputDir, compileName, OutputCacheManager.CACHE_SUBDIR, - buildId, - 'output.pdf' + buildId ) - try { - await fs.access(pdfPath) - } catch { - return res.sendStatus(404) + let pdfPath = null + let deckTapeDir = null + + for (const name of ['output.pdf', 'output-slides.pdf']) { + try { + const p = Path.join(buildDir, name) + await fs.access(p) + pdfPath = p + break + } catch {} + } + + if (!pdfPath) { + const htmlPath = Path.join(buildDir, 'output.html') + try { + await fs.access(htmlPath) + deckTapeDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-deck-')) + const chromeHome = Path.join(deckTapeDir, 'chrome') + await fs.mkdir(chromeHome, { recursive: true }) + const slidePdf = Path.join(deckTapeDir, 'slide1.pdf') + await execFileAsync( + 'decktape', + [ + '--slides', '1', + '--chrome-arg=--no-sandbox', + '--chrome-arg=--disable-dev-shm-usage', + '--chrome-arg=--disable-gpu', + `--chrome-arg=--user-data-dir=${chromeHome}/data`, + htmlPath, + slidePdf, + ], + { + timeout: 60000, + env: { + ...process.env, + HOME: chromeHome, + XDG_CONFIG_HOME: chromeHome, + XDG_CACHE_HOME: chromeHome, + }, + } + ) + pdfPath = slidePdf + } catch (err) { + logger.warn({ err, projectId, buildId }, 'decktape slide1 thumbnail failed') + if (deckTapeDir) { + await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {}) + deckTapeDir = null + } + return res.sendStatus(404) + } } const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-')) @@ -311,6 +358,9 @@ async function thumbnailFromBuild(req, res) { if (!res.headersSent) res.sendStatus(500) } finally { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + if (deckTapeDir) { + await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {}) + } } }