feat: generate first-slide thumbnail for Quarto RevealJS presentations
Build and Deploy Verso / deploy (push) Successful in 14m10s
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:
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user