Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/api/src/routes/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from "node:path"
import { Hono } from "hono"
import { HTTPException } from "hono/http-exception"
import { parseBookLabel, PIPELINE } from "@adt/types"
import { openBookDb } from "@adt/storage"
import { openBookDb, resolveBookPaths } from "@adt/storage"
import {
listBooks,
getBook,
Expand Down Expand Up @@ -360,6 +360,38 @@ export function createBookRoutes(
}
})

// GET /books/:label/thumbnails/:filename — Serve section preview thumbnail PNG
app.get("/books/:label/thumbnails/:filename", (c) => {
const { label, filename } = c.req.param()
let paths: ReturnType<typeof resolveBookPaths>
try {
paths = resolveBookPaths(label, booksDir)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new HTTPException(400, { message })
}
if (!/^[a-zA-Z0-9_-]+\.png$/.test(filename)) {
throw new HTTPException(400, { message: "Invalid thumbnail filename" })
}
const thumbPath = path.resolve(paths.thumbnailsDir, filename)
if (!thumbPath.startsWith(path.resolve(paths.thumbnailsDir) + path.sep)) {
throw new HTTPException(400, { message: "Invalid thumbnail path" })
}
let stat: fs.Stats
try {
stat = fs.statSync(thumbPath)
} catch {
throw new HTTPException(404, { message: `Thumbnail not found: ${filename}` })
}
if (!stat.isFile()) {
throw new HTTPException(404, { message: `Thumbnail not found: ${filename}` })
}
const buffer = fs.readFileSync(thumbPath)
c.header("Content-Type", "image/png")
c.header("Cache-Control", "public, max-age=3600")
return c.body(buffer)
})

// GET /books/:label/adt/* — Serve packaged ADT static files
// Supports an optional cache-bust version segment: /adt/v-{ts}/page.html
// The version segment is stripped before resolving files, so all relative
Expand Down
68 changes: 61 additions & 7 deletions apps/api/src/services/page-edit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import path from "node:path"
import { createBookStorage } from "@adt/storage"
import { createLLMModel, createPromptEngine } from "@adt/llm"
import type { LLMModel } from "@adt/llm"
import { renderPage, buildRenderStrategyResolver, createTemplateEngine, loadBookConfig, createScreenshotRenderer, runVisualReviewLoop, DEFAULT_VISUAL_REVIEW_MODEL_ID, structurePage, buildStructureConfig } from "@adt/pipeline"
import type { VisualRefinementDeps } from "@adt/pipeline"
import { PageSectioningOutput, WebRenderingOutput, webRenderingLLMSchema, ImageClassificationOutput } from "@adt/types"
import { renderPage, buildRenderStrategyResolver, createTemplateEngine, loadBookConfig, createScreenshotRenderer, runVisualReviewLoop, DEFAULT_VISUAL_REVIEW_MODEL_ID, structurePage, buildStructureConfig, renderSectionThumbnail } from "@adt/pipeline"
import type { VisualRefinementDeps, ScreenshotRenderer } from "@adt/pipeline"
import { PageSectioningOutput, WebRenderingOutput, webRenderingLLMSchema, ImageClassificationOutput, type ContentNodeData } from "@adt/types"
import { loadStyleguideContent } from "./styleguide.js"

export interface ReRenderOptions {
Expand Down Expand Up @@ -70,6 +70,7 @@ export async function reRenderPage(

const storage = createBookStorage(label, booksDir)
let visualRefinement: VisualRefinementDeps | undefined
let screenshotRenderer: ScreenshotRenderer | undefined

try {
// Read latest pipeline data
Expand Down Expand Up @@ -100,6 +101,8 @@ export async function reRenderPage(
if (part.type === "image" && !part.isPruned && !renderImages.has(part.imageId)) {
const dims = storage.getImageDimensions(part.imageId)
renderImages.set(part.imageId, { base64: storage.getImageBase64(part.imageId), width: dims?.width, height: dims?.height })
} else if (part.type === "content_node" && !part.isPruned) {
collectImageIdsFromNode(part.node, renderImages, storage)
}
}
}
Expand Down Expand Up @@ -137,13 +140,13 @@ export async function reRenderPage(
throw new Error(`Section index ${sectionIndex} out of range`)
}

// Set up visual refinement if any render strategy enables it
// Set up shared screenshot renderer for visual refinement + thumbnails
if (webAssetsDir) {
const hasVisualRefinement = Object.values(config.render_strategies ?? {}).some(
(s) => s.config?.visual_refinement?.enabled
)
screenshotRenderer = await createScreenshotRenderer()
if (hasVisualRefinement) {
const screenshotRenderer = await createScreenshotRenderer()
visualRefinement = {
screenshotRenderer,
webAssetsDir,
Expand All @@ -156,6 +159,28 @@ export async function reRenderPage(
}
}

const captureThumbnails = async (rendering: WebRenderingOutput): Promise<void> => {
if (!screenshotRenderer || !webAssetsDir) return
const thumbImages = new Map<string, { base64: string }>()
for (const [id, img] of renderImages) thumbImages.set(id, { base64: img.base64 })
for (const section of rendering.sections) {
try {
const buffer = await renderSectionThumbnail({
section,
label,
images: thumbImages,
webAssetsDir,
screenshotRenderer,
})
storage.putSectionThumbnail(pageId, section.sectionIndex, buffer)
} catch (err) {
console.error(
`[page-edit] ${label}: thumbnail failed for ${pageId} sec${section.sectionIndex}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
}

// Render either a single section (preferred) or the full page.
// For section re-render we force all other sections to pruned in-memory so
// renderPage preserves the original sectionIndex while skipping extra LLM calls.
Expand Down Expand Up @@ -186,6 +211,7 @@ export async function reRenderPage(

if (sectionIndex === undefined) {
const version = storage.putNodeData("web-rendering", pageId, renderResult)
await captureThumbnails(renderResult)
return { version, rendering: renderResult }
}

Expand All @@ -209,10 +235,11 @@ export async function reRenderPage(
const mergedRendering = { sections: mergedSections }

const version = storage.putNodeData("web-rendering", pageId, mergedRendering)
await captureThumbnails(mergedRendering)
return { version, rendering: mergedRendering }
} finally {
if (visualRefinement) {
await visualRefinement.screenshotRenderer.close()
if (screenshotRenderer) {
await screenshotRenderer.close()
}
storage.clearNodesByType(["image-captioning", "text-catalog", "text-catalog-translation", "tts", "tts-timestamps"])
storage.clearStepRuns(["image-captioning", "text-catalog", "catalog-translation", "tts"])
Expand Down Expand Up @@ -507,3 +534,30 @@ export async function reStructurePage(
}
}
}

/**
* Recursively collect image IDs from a content node tree and add them to the render images map.
*/
function collectImageIdsFromNode(
node: ContentNodeData,
renderImages: Map<string, { base64: string; width?: number; height?: number }>,
storage: { getImageBase64: (id: string) => string; getImageDimensions: (id: string) => { width: number; height: number } | null }
): void {
if (node.isPruned) return
const ensureLoaded = (imageId: string) => {
if (renderImages.has(imageId)) return
try {
const dims = storage.getImageDimensions(imageId)
renderImages.set(imageId, { base64: storage.getImageBase64(imageId), width: dims?.width, height: dims?.height })
} catch {
// Image not found in storage — skip
}
}
if (node.imageId) ensureLoaded(node.imageId)
if (node.backgroundImageId) ensureLoaded(node.backgroundImageId)
if (node.children) {
for (const child of node.children) {
collectImageIdsFromNode(child, renderImages, storage)
}
}
}
47 changes: 41 additions & 6 deletions apps/api/src/services/stage-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ import {
getSegmentedImageId,
createScreenshotRenderer,
DEFAULT_VISUAL_REVIEW_MODEL_ID,
renderSectionThumbnail,
} from "@adt/pipeline"
import type { TranslationConfig, QuizPageInput, ProviderRouting, MeaningfulnessConfig, CroppingConfig, SegmentationConfig, VisualRefinementDeps } from "@adt/pipeline"
import type { TranslationConfig, QuizPageInput, ProviderRouting, MeaningfulnessConfig, CroppingConfig, SegmentationConfig, VisualRefinementDeps, ScreenshotRenderer } from "@adt/pipeline"
import { loadStyleguideContent } from "./styleguide.js"
import { createTTSSynthesizer, createAzureTTSSynthesizer, createGeminiTTSSynthesizer } from "@adt/llm"
import type { TTSSynthesizer } from "@adt/llm"
Expand Down Expand Up @@ -563,6 +564,7 @@ async function runStoryboardStep(

const storage = createBookStorage(label, booksDir)
let visualRefinement: VisualRefinementDeps | undefined
let screenshotRenderer: ScreenshotRenderer | undefined

try {
const config = loadBookConfig(label, booksDir, configPath)
Expand Down Expand Up @@ -617,13 +619,13 @@ async function runStoryboardStep(
return model
}

// Set up visual refinement if any render strategy enables it
// Set up shared screenshot renderer for visual refinement + thumbnails
if (webAssetsDir) {
const hasVisualRefinement = Object.values(config.render_strategies ?? {}).some(
(s) => s.config?.visual_refinement?.enabled
)
screenshotRenderer = await createScreenshotRenderer()
if (hasVisualRefinement) {
const screenshotRenderer = await createScreenshotRenderer()
visualRefinement = {
screenshotRenderer,
webAssetsDir,
Expand All @@ -636,6 +638,32 @@ async function runStoryboardStep(
}
}

const captureThumbnails = async (
pageId: string,
renderResult: WebRenderingOutput,
renderImages: Map<string, { base64: string; width?: number; height?: number }>
): Promise<void> => {
if (!screenshotRenderer || !webAssetsDir) return
const thumbImages = new Map<string, { base64: string }>()
for (const [id, img] of renderImages) thumbImages.set(id, { base64: img.base64 })
for (const section of renderResult.sections) {
try {
const buffer = await renderSectionThumbnail({
section,
label,
images: thumbImages,
webAssetsDir,
screenshotRenderer,
})
storage.putSectionThumbnail(pageId, section.sectionIndex, buffer)
} catch (err) {
console.error(
`[stage-run] ${label}: thumbnail failed for ${pageId} sec${section.sectionIndex}: ${toErrorMessage(err)}`
)
}
}
}

// Get all pages
const pages = storage.getPages()
const totalPages = pages.length
Expand Down Expand Up @@ -704,6 +732,7 @@ async function runStoryboardStep(
visualRefinement,
)
storage.putNodeData("web-rendering", page.pageId, renderResult)
await captureThumbnails(page.pageId, renderResult, renderImages)
completedRendering++
progress.emit({
type: "step-progress",
Expand Down Expand Up @@ -771,7 +800,11 @@ async function runStoryboardStep(
)
return
}
const textClassification = textClassificationRow.data as TextClassificationOutput
// Detect tree-based data (has `nodes` array) vs legacy flat data (has `groups` array)
const rawData = textClassificationRow.data as Record<string, unknown>
const isTree = rawData && Array.isArray(rawData.nodes)
const contentTree = isTree ? (rawData as unknown as PageStructuringOutput) : undefined
const textClassification = !isTree ? (rawData as unknown as TextClassificationOutput) : undefined

// Get image-filtering data
const imageClassificationRow = storage.getLatestNodeData(
Expand Down Expand Up @@ -807,6 +840,7 @@ async function runStoryboardStep(
pageNumber: page.pageNumber,
pageImageBase64,
textClassification,
contentTree,
imageClassification,
images: sectionImages,
},
Expand Down Expand Up @@ -851,6 +885,7 @@ async function runStoryboardStep(
visualRefinement,
)
storage.putNodeData("web-rendering", page.pageId, renderResult)
await captureThumbnails(page.pageId, renderResult, renderImages)
completedRendering++
progress.emit({
type: "step-progress",
Expand Down Expand Up @@ -888,8 +923,8 @@ async function runStoryboardStep(
console.log(`[stage-run] ${label}: storyboard complete`)
}
} finally {
if (visualRefinement) {
await visualRefinement.screenshotRenderer.close()
if (screenshotRenderer) {
await screenshotRenderer.close()
}
storage.close()
restoreEnvKeys()
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ export default [
// Tailwind CSS classes, internal identifiers, and status values
// (all-lowercase-no-spaces: bg-gray-600, hover:bg-white, gap-2.5, "success", "error", "done")
"^[a-z][a-z0-9._:-]*$",
// camelCase identifier strings used as state keys / enum values (e.g. "textGroups", "prunedImages")
"^[a-z][a-zA-Z0-9]*$",
// Multi-class Tailwind strings (space-separated tokens, e.g. "bg-red-600 text-white hover:bg-red-700")
"^[a-z][a-z0-9._:/-]*( [a-z!][a-z0-9._:/-]*)+$",
// Hex color values (e.g. "#ffffff", "#2563eb")
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ export interface PageDetail {
isPruned: boolean
reason?: string
}
| {
type: "content_node"
nodeId: string
node: ContentNodeData
isPruned: boolean
}
>
backgroundColor: string
textColor: string
Expand Down
Loading
Loading