feat: generate first-slide thumbnail for Quarto RevealJS presentations
Build and Deploy Verso / deploy (push) Successful in 14m10s

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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-13 16:02:54 +00:00
parent 1ddd219449
commit c5883e5954
+58 -8
View File
@@ -256,27 +256,74 @@ 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
)
let pdfPath = null
let deckTapeDir = null
for (const name of ['output.pdf', 'output-slides.pdf']) {
try {
await fs.access(pdfPath)
} catch {
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-'))
const outputBase = Path.join(tmpDir, '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(() => {})
}
}
}