diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index 4ba1e89..4360a58 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -16,6 +16,7 @@ export { } from './config/timeout'; export { renderPage } from './pdf/render'; export { mergeTextWithRegions } from './pdf/merge'; +export { PDF_PARSER_VERSION } from './pdf/parser-version'; export { stitchCrossPageBlocks } from './pdf/stitch'; export { normalizeTextItemsForLayout } from './pdf/normalize-text'; export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map'; diff --git a/compute/core/src/pdf/parse.ts b/compute/core/src/pdf/parse.ts index 4cf0cd3..f0bde04 100644 --- a/compute/core/src/pdf/parse.ts +++ b/compute/core/src/pdf/parse.ts @@ -4,6 +4,7 @@ import { ensureModel } from './model'; import { runLayoutModel } from './runLayoutModel'; import { mergeTextWithRegions } from './merge'; import { resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime'; +import { PDF_PARSER_VERSION } from './parser-version'; import { stitchCrossPageBlocks } from './stitch'; import { renderPage } from './render'; import { normalizeTextItemsForLayout } from './normalize-text'; @@ -143,7 +144,7 @@ export async function parsePdf(input: ParsePdfInput): Promise const doc: ParsedPdfDocument = { schemaVersion: 1, documentId: input.documentId, - parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69', + parserVersion: PDF_PARSER_VERSION, parsedAt: Date.now(), pages, }; diff --git a/compute/core/src/pdf/parser-version.ts b/compute/core/src/pdf/parser-version.ts new file mode 100644 index 0000000..b5fa7d7 --- /dev/null +++ b/compute/core/src/pdf/parser-version.ts @@ -0,0 +1 @@ +export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69'; diff --git a/compute/core/src/pdf/pdfjs-runtime.ts b/compute/core/src/pdf/pdfjs-runtime.ts index 206e650..233d9c3 100644 --- a/compute/core/src/pdf/pdfjs-runtime.ts +++ b/compute/core/src/pdf/pdfjs-runtime.ts @@ -1,12 +1,15 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; let cachedStandardFontDataUrl: string | null = null; +const require = createRequire(import.meta.url); export function resolvePdfjsStandardFontDataUrl(): string { if (cachedStandardFontDataUrl) return cachedStandardFontDataUrl; - const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const pdfjsPackageDir = path.dirname(require.resolve('pdfjs-dist/package.json')); + const standardFontDir = path.join(pdfjsPackageDir, 'standard_fonts'); if (!fs.existsSync(standardFontDir)) { throw new Error(`pdfjs-dist standard_fonts directory not found at ${standardFontDir}`); diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 4a25c2e..b08d4d4 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -31,6 +31,7 @@ export default function EPUBPage() { const { setCurrentDocument, currDocName, + isPlaybackReady, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, @@ -242,7 +243,7 @@ export default function EPUBPage() { ) : ( - + )} Promise; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; @@ -106,6 +107,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); // Mirror state into a ref so resolveEpubLocator (registered once with // TTSContext via a stable callback) can always read the latest page text // without forcing re-registration on every page turn. @@ -160,6 +162,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { setCurrDocData(undefined); setCurrDocName(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); setCurrDocPages(undefined); isEPUBSetOnce.current = false; shouldPauseRef.current = true; @@ -179,6 +182,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { + setIsPlaybackReady(false); const meta = await getDocumentMetadata(id); if (!meta) { clearCurrDoc(); @@ -215,6 +219,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { */ const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise => { try { + setIsPlaybackReady(false); const location = rendition?.location; if (!location) return ''; const { start, end } = location; @@ -244,6 +249,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { nextText: continuationPreview }); setCurrDocText(textContent); + setIsPlaybackReady(true); return textContent; } catch (error) { @@ -390,6 +396,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, @@ -415,6 +422,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index f0193a3..09a0b45 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -31,6 +31,7 @@ export default function HTMLPage() { setCurrentDocument, currDocData, currDocName, + isPlaybackReady, blocks, isTxt, clearCurrDoc, @@ -233,7 +234,7 @@ export default function HTMLPage() { ) : ( - + )} Promise; @@ -73,6 +74,7 @@ export function useHtmlDocument(): HtmlDocumentState { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); const isTxt = useMemo(() => isTxtName(currDocName), [currDocName]); const blocks = useMemo( @@ -91,22 +93,36 @@ export function useHtmlDocument(): HtmlDocumentState { // sentence splitting + sequential advancement from there. const lastFedDocRef = useRef(null); useEffect(() => { - if (!currDocText) return; + if (currDocData === undefined) { + setIsPlaybackReady(false); + return; + } + if (!currDocText) { + setIsPlaybackReady(true); + return; + } const key = `${currDocData ?? ''}::${currDocText.length}`; - if (lastFedDocRef.current === key) return; + if (lastFedDocRef.current === key) { + setIsPlaybackReady(true); + return; + } + setIsPlaybackReady(false); lastFedDocRef.current = key; setTTSText(currDocText); + setIsPlaybackReady(true); }, [currDocText, currDocData, setTTSText]); const clearCurrDoc = useCallback(() => { setCurrDocData(undefined); setCurrDocName(undefined); + setIsPlaybackReady(false); lastFedDocRef.current = null; stop(); }, [stop]); const setCurrentDocument = useCallback(async (id: string): Promise => { try { + setIsPlaybackReady(false); const meta = await getDocumentMetadata(id); if (!meta) { console.error('Document not found on server'); @@ -207,6 +223,7 @@ export function useHtmlDocument(): HtmlDocumentState { currDocData, currDocName, currDocText, + isPlaybackReady, blocks, isTxt, setCurrentDocument, @@ -218,6 +235,7 @@ export function useHtmlDocument(): HtmlDocumentState { currDocData, currDocName, currDocText, + isPlaybackReady, blocks, isTxt, setCurrentDocument, diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 79d4060..6cfe8cf 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -52,6 +52,7 @@ export default function PDFViewerPage() { clearCurrDoc, currDocPage, currDocPages, + isPlaybackReady, parseStatus, parseProgress, documentSettings, @@ -487,7 +488,7 @@ export default function PDFViewerPage() { ) : isParseReady ? ( - + ) : null} (); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isPlaybackReady, setIsPlaybackReady] = useState(false); const [pdfDocument, setPdfDocument] = useState(); const [parsedDocument, setParsedDocument] = useState(null); const [parseStatus, setParseStatus] = useState(null); @@ -169,6 +171,7 @@ export function usePdfDocument(): PdfDocumentState { const docLoadAbortRef = useRef(null); const parsePollAbortRef = useRef(null); const parseSseCloseRef = useRef<(() => void) | null>(null); + const lastPreparedPlaybackPageRef = useRef(null); const fetchParsedDocument = useCallback(async ( documentId: string, @@ -248,6 +251,8 @@ export function usePdfDocument(): PdfDocumentState { if (snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed') { if (snapshot.parseStatus === 'failed') { setParsedDocument(null); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setActiveParseOpId(null); } else { void fetchParsedDocument( @@ -297,6 +302,7 @@ export function usePdfDocument(): PdfDocumentState { useEffect(() => { setCurrDocPage(currDocPageNumber); + setIsPlaybackReady(false); }, [currDocPageNumber]); /** @@ -324,11 +330,13 @@ export function usePdfDocument(): PdfDocumentState { if (!currentPdf) return; const seq = ++loadSeqRef.current; const pageNumber = currDocPageNumber; + setIsPlaybackReady(false); const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => parsedDocument?.pages.find((page) => page.pageNumber === pageNum); if (parseStatus !== 'ready' || !parsedDocument) { + lastPreparedPlaybackPageRef.current = null; setCurrDocText(undefined); setTTSText('', { location: currDocPageNumber }); return; @@ -401,7 +409,8 @@ export function usePdfDocument(): PdfDocumentState { return; } - if (text !== currDocText || text === '') { + const shouldPreparePlayback = text === '' || text !== currDocText || lastPreparedPlaybackPageRef.current !== currDocPageNumber; + if (shouldPreparePlayback) { setCurrDocText(text); const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { @@ -414,6 +423,8 @@ export function usePdfDocument(): PdfDocumentState { ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } + lastPreparedPlaybackPageRef.current = currDocPageNumber; + setIsPlaybackReady(true); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return; @@ -469,6 +480,8 @@ export function usePdfDocument(): PdfDocumentState { setPdfDocument(undefined); setCurrDocPages(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); @@ -546,6 +559,8 @@ export function usePdfDocument(): PdfDocumentState { pageTextCacheRef.current.clear(); setParsedDocument(null); setCurrDocText(undefined); + setIsPlaybackReady(false); + lastPreparedPlaybackPageRef.current = null; setParseStatus(forced.status); setParseProgress(null); setActiveParseOpId(forced.opId ?? null); @@ -575,6 +590,7 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocName(undefined); setCurrDocData(undefined); setCurrDocText(undefined); + setIsPlaybackReady(false); setCurrDocPages(undefined); setPdfDocument(undefined); setParsedDocument(null); @@ -582,6 +598,7 @@ export function usePdfDocument(): PdfDocumentState { setParseProgress(null); setActiveParseOpId(null); setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); + lastPreparedPlaybackPageRef.current = null; pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); @@ -681,6 +698,7 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, parsedDocument, parseStatus, parseProgress, @@ -708,6 +726,7 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + isPlaybackReady, parsedDocument, parseStatus, parseProgress, diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index e1adc9d..79d6801 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -9,6 +9,7 @@ import { isWorkerOperationStateStale, snapshotFromWorkerState } from '@/lib/serv import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, stringifyDocumentParseState, @@ -67,7 +68,7 @@ async function toSnapshotState(row: ParseRow, preferredOpId?: string | null): Pr const state = await healStaleDocumentParseState({ documentId: row.id, userId: row.userId, - state: parseDocumentParseState(row.parseState), + state: normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)), }); const parseStatus = normalizeParseStatus(state.status); const dbOpId = typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null; @@ -199,7 +200,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string let initialState = await toSnapshotState(row, requestedOpId); if (!requestedOpId && !initialState.opId && initialState.snapshot.parseStatus !== 'ready' && initialState.snapshot.parseStatus !== 'failed') { - const state = parseDocumentParseState(row.parseState); + const state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); const created = await backfillPendingPdfParseOperation({ documentId: id, userId: row.userId, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 650760c..11158ea 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -20,8 +20,10 @@ import { putParsedDocumentBlob, } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, + resolveParsedPdfParserVersion, stringifyDocumentParseState, } from '@/lib/server/documents/parse-state'; import { backfillPendingPdfParseOperation } from '@/lib/server/documents/parse-state-backfill'; @@ -156,13 +158,6 @@ async function finalizeFromWorkerState(input: { }); } - await writeParseRowState({ - documentId: input.row.id, - userId: input.row.userId, - parseState: stringifyDocumentParseState(workerParseState), - parsedJsonKey, - }); - const json = await getParsedDocumentBlobByKey(parsedJsonKey); let parsedDoc: ParsedPdfDocument | null = null; try { @@ -171,6 +166,16 @@ async function finalizeFromWorkerState(input: { parsedDoc = null; } + await writeParseRowState({ + documentId: input.row.id, + userId: input.row.userId, + parseState: stringifyDocumentParseState({ + ...workerParseState, + parserVersion: resolveParsedPdfParserVersion(parsedDoc), + }), + parsedJsonKey, + }); + if (!hasAnyParsedBlocks(parsedDoc)) { input.logger.warn({ event: 'documents.parsed.no_blocks_in_output', @@ -246,7 +251,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }); } - let state = parseDocumentParseState(row.parseState); + let state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); state = await healStaleDocumentParseState({ documentId: id, userId: row.userId, @@ -409,7 +414,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - let state = parseDocumentParseState(row.parseState); + let state = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); state = await healStaleDocumentParseState({ documentId: id, userId: row.userId, diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 7ba5623..10d1f23 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -19,6 +19,7 @@ import { isS3Configured } from '@/lib/server/storage/s3'; import { putDocumentBlob } from '@/lib/server/documents/blobstore'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); @@ -142,7 +143,12 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, - parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }), parsedJsonKey: null, }) .onConflictDoUpdate({ @@ -153,7 +159,12 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, - parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }), parsedJsonKey: null, }, }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 903ab96..7ea9f40 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -17,12 +17,15 @@ import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { + normalizeDocumentParseStateForCurrentParserVersion, normalizeParseStatus, parseDocumentParseState, stringifyDocumentParseState, } from '@/lib/server/documents/parse-state'; +import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { BaseDocument, DocumentType } from '@/types/documents'; export const dynamic = 'force-dynamic'; @@ -100,6 +103,9 @@ export async function POST(req: NextRequest) { for (const doc of documentsData) { let headSize = doc.size; + const reusableParsedPdf = doc.type === 'pdf' + ? await findReusableParsedPdfResult(doc.id) + : null; // Retry HEAD check to handle S3 read-after-write propagation delays. // The client uploads bytes directly to S3 via presigned URL, then @@ -146,9 +152,13 @@ export async function POST(req: NextRequest) { lastModified: doc.lastModified, filePath: doc.id, parseState: doc.type === 'pdf' - ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + ? stringifyDocumentParseState( + reusableParsedPdf + ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } + : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, + ) : null, - parsedJsonKey: null, + parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -159,9 +169,13 @@ export async function POST(req: NextRequest) { lastModified: doc.lastModified, filePath: doc.id, parseState: doc.type === 'pdf' - ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + ? stringifyDocumentParseState( + reusableParsedPdf + ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } + : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, + ) : null, - parsedJsonKey: null, + parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, }, }); @@ -191,7 +205,7 @@ export async function POST(req: NextRequest) { }, 'Failed to enqueue document preview'); }); - if (doc.type === 'pdf') { + if (doc.type === 'pdf' && !reusableParsedPdf) { // Account for upload-driven parse load in the same ledger the explicit // re-parse limiter reads. We record (not reject) here so a legitimate // bulk upload always parses; the recorded load still throttles @@ -260,13 +274,16 @@ export async function GET(req: NextRequest) { const results: BaseDocument[] = rows.map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); + const parseState = type === 'pdf' + ? normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(doc.parseState)) + : null; return { id: doc.id, name: doc.name, size: Number(doc.size), lastModified: Number(doc.lastModified), type, - parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null, + parseStatus: type === 'pdf' && parseState ? normalizeParseStatus(parseState.status) : null, parsedJsonKey: doc.parsedJsonKey, scope: 'user', }; diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index 1badd9b..a93ddbd 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -13,9 +13,10 @@ import { SpeedControl } from '@/components/player/SpeedControl'; import { Navigator } from '@/components/player/Navigator'; import { IconButton } from '@/components/ui'; -export default function TTSPlayer({ currentPage, numPages }: { +export default function TTSPlayer({ currentPage, numPages, isPlaybackReady = true }: { currentPage?: number; numPages?: number | undefined; + isPlaybackReady?: boolean; }) { const { isPlaying, @@ -52,7 +53,7 @@ export default function TTSPlayer({ currentPage, numPages }: { {isProcessing ? : } @@ -61,16 +62,18 @@ export default function TTSPlayer({ currentPage, numPages }: { - {isPlaying ? : } + {!isPlaying && !isPlaybackReady + ? + : (isPlaying ? : )} {isProcessing ? : } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index f1e0e9e..b346baf 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -624,6 +624,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [currentWordIndex, setCurrentWordIndex] = useState(null); const isPlayingRef = useRef(false); const pauseEpochRef = useRef(0); + const pendingPlaybackStartRef = useRef(false); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); const plannedSegmentsByLocationRef = useRef>(new Map()); @@ -939,6 +940,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const recordManualPause = useCallback(() => { // Cancel any queued auto-resume intent and mark an explicit user pause. resumeAfterLocationChangeRef.current = false; + pendingPlaybackStartRef.current = false; pauseEpochRef.current += 1; }, []); @@ -947,6 +949,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * Used for external control of playback state */ const pause = useCallback(() => { + pendingPlaybackStartRef.current = false; recordManualPause(); clearPendingEpubJump(); abortPendingTtsRequests(true); @@ -1248,10 +1251,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const shouldPause = normalizedOptions.shouldPause ?? false; const pauseEpochAtStart = pauseEpochRef.current; const pendingAutoResume = resumeAfterLocationChangeRef.current; - const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume); - if (shouldPause || pendingAutoResume) { - resumeAfterLocationChangeRef.current = false; - } + const pendingPlaybackStart = pendingPlaybackStartRef.current; + const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume || pendingPlaybackStart); // Keep track of previous state and pause playback invalidatePlaybackRun(); @@ -1262,10 +1263,23 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement try { if (newSentences.length === 0) { console.warn('No sentences found in text'); + if (shouldPause || pendingAutoResume) { + resumeAfterLocationChangeRef.current = false; + } + if (pendingPlaybackStart) { + pendingPlaybackStartRef.current = false; + } setIsProcessing(false); return; } + if (shouldPause || pendingAutoResume) { + resumeAfterLocationChangeRef.current = false; + } + if (pendingPlaybackStart) { + pendingPlaybackStartRef.current = false; + } + if (!isEPUB && typeof resolvedLocation === 'number') { const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); if (firstFingerprint) { @@ -1388,6 +1402,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Ensure audio is unlocked while we're still in the click/tap handler. unlockPlaybackOnUserGesture(); + const hasPreparedSentence = Boolean(playbackSegments[currentIndex]?.text ?? sentences[currentIndex]); + if (!activeHowl && !hasPreparedSentence) { + pendingPlaybackStartRef.current = true; + setIsProcessing(true); + return; + } + // Resume current sentence if we already have a paused Howl. if (activeHowl) { applyPlaybackRateToHowl(activeHowl); @@ -1406,12 +1427,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setIsPlaying(true); }, [ activeHowl, + currentIndex, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, + playbackSegments, recordManualPause, clearPendingEpubJump, abortPendingTtsRequests, + sentences, unlockPlaybackOnUserGesture, ]); @@ -2878,6 +2902,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio(); clearWarmAudioCache(); playbackInFlightRef.current = false; + pendingPlaybackStartRef.current = false; pendingJumpTargetRef.current = null; clearPendingEpubJump(); bumpEpubPreloadGeneration(); diff --git a/src/lib/server/compute/worker-parse-state.ts b/src/lib/server/compute/worker-parse-state.ts index cce75a4..6798a22 100644 --- a/src/lib/server/compute/worker-parse-state.ts +++ b/src/lib/server/compute/worker-parse-state.ts @@ -1,3 +1,4 @@ +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { DocumentParseState } from '@/lib/server/documents/parse-state'; @@ -42,6 +43,7 @@ export function documentParseStateFromWorkerState( ? parseProgress : null, updatedAt: nowMs, + parserVersion: PDF_PARSER_VERSION, ...(typeof state.opId === 'string' && state.opId.trim() ? { opId: state.opId } : {}), ...(typeof state.jobId === 'string' && state.jobId.trim() ? { jobId: state.jobId } : {}), ...(parseStatus === 'failed' && state.error?.message ? { error: state.error.message } : {}), diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 6f94501..cdc79cc 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; -import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core'; +import { getWorkerClientWaitTimeoutMs, PDF_PARSER_VERSION } from '@openreader/compute-core'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import type { @@ -190,6 +190,7 @@ export function buildPdfOpKey(input: PdfLayoutInput): string { return [ 'pdf_layout', 'v1', + PDF_PARSER_VERSION, input.documentId, input.namespace ?? '', input.documentObjectKey ?? '', diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts index 82ca5dc..d8fcbd4 100644 --- a/src/lib/server/documents/parse-state-healing.ts +++ b/src/lib/server/documents/parse-state-healing.ts @@ -24,6 +24,7 @@ export async function healStaleDocumentParseState(input: { error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`, ...(input.state.opId ? { opId: input.state.opId } : {}), ...(input.state.jobId ? { jobId: input.state.jobId } : {}), + ...(input.state.parserVersion ? { parserVersion: input.state.parserVersion } : {}), })); await db diff --git a/src/lib/server/documents/parse-state.ts b/src/lib/server/documents/parse-state.ts index 431e20c..22a94ad 100644 --- a/src/lib/server/documents/parse-state.ts +++ b/src/lib/server/documents/parse-state.ts @@ -1,4 +1,5 @@ -import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import type { ParsedPdfDocument, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; export interface DocumentParseState { status: PdfParseStatus; @@ -7,6 +8,7 @@ export interface DocumentParseState { error?: string | null; opId?: string; jobId?: string; + parserVersion?: string; } export function isInProgressParseStatus(status: PdfParseStatus): status is 'pending' | 'running' { @@ -32,6 +34,31 @@ export function normalizeParseStatus(status: string | null | undefined): PdfPars return 'pending'; } +export function hasCurrentPdfParserVersion(parserVersion: string | null | undefined): boolean { + return typeof parserVersion === 'string' && parserVersion.trim() === PDF_PARSER_VERSION; +} + +export function normalizeDocumentParseStateForCurrentParserVersion( + state: DocumentParseState, + nowMs = Date.now(), +): DocumentParseState { + const status = normalizeParseStatus(state.status); + if (status === 'failed' || hasCurrentPdfParserVersion(state.parserVersion)) { + return state; + } + + return { + status: 'pending', + progress: null, + updatedAt: nowMs, + }; +} + +export function resolveParsedPdfParserVersion(parsed: ParsedPdfDocument | null | undefined): string { + const parserVersion = parsed?.parserVersion?.trim(); + return parserVersion || PDF_PARSER_VERSION; +} + function normalizeProgress(progress: unknown): PdfParseProgress | null { if (!progress || typeof progress !== 'object') return null; const rec = progress as Record; @@ -63,6 +90,7 @@ export function parseDocumentParseState(value: string | null): DocumentParseStat const error = typeof parsed.error === 'string' ? parsed.error : null; const opId = typeof parsed.opId === 'string' ? parsed.opId : undefined; const jobId = typeof parsed.jobId === 'string' ? parsed.jobId : undefined; + const parserVersion = typeof parsed.parserVersion === 'string' ? parsed.parserVersion : undefined; return { status, progress, @@ -70,6 +98,7 @@ export function parseDocumentParseState(value: string | null): DocumentParseStat ...(error ? { error } : {}), ...(opId ? { opId } : {}), ...(jobId ? { jobId } : {}), + ...(parserVersion ? { parserVersion } : {}), }; } catch { return { status: 'pending', progress: null }; @@ -84,5 +113,6 @@ export function stringifyDocumentParseState(state: DocumentParseState): string { ...(state.error ? { error: state.error } : {}), ...(state.opId ? { opId: state.opId } : {}), ...(state.jobId ? { jobId: state.jobId } : {}), + ...(state.parserVersion ? { parserVersion: state.parserVersion } : {}), }); } diff --git a/src/lib/server/documents/parsed-pdf-reuse.ts b/src/lib/server/documents/parsed-pdf-reuse.ts new file mode 100644 index 0000000..1bbede4 --- /dev/null +++ b/src/lib/server/documents/parsed-pdf-reuse.ts @@ -0,0 +1,33 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { + normalizeDocumentParseStateForCurrentParserVersion, + parseDocumentParseState, +} from '@/lib/server/documents/parse-state'; + +type ReadyParsedPdfResult = { + parsedJsonKey: string; +}; + +export async function findReusableParsedPdfResult( + documentId: string, +): Promise { + const rows = await db + .select({ + parseState: documents.parseState, + parsedJsonKey: documents.parsedJsonKey, + }) + .from(documents) + .where(eq(documents.id, documentId)); + + for (const row of rows) { + const parsedJsonKey = row.parsedJsonKey?.trim(); + if (!parsedJsonKey) continue; + const parseState = normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState)); + if (parseState.status !== 'ready') continue; + return { parsedJsonKey }; + } + + return null; +} diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 341fd55..531dfd0 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -1,11 +1,14 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { documentKey, getParsedDocumentBlobByKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import { + normalizeDocumentParseStateForCurrentParserVersion, parseDocumentParseState, + resolveParsedPdfParserVersion, stringifyDocumentParseState, type DocumentParseState, } from '@/lib/server/documents/parse-state'; @@ -13,11 +16,13 @@ import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; import type { + ParsedPdfDocument, PdfLayoutJobBase, PdfLayoutJobResult, PdfLayoutProgress, WorkerOperationState, } from '@openreader/compute-core/api-contracts'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; type UserPdfLayoutJobRequest = PdfLayoutJobBase & { userId: string; @@ -41,7 +46,7 @@ function keyFor(input: UserPdfLayoutJobRequest): string { if (forceToken) { return `force:${input.documentId}:${input.namespace || ''}:${forceToken}`; } - return `shared:${input.documentId}:${input.namespace || ''}`; + return `shared:${input.documentId}`; } function sleep(ms: number): Promise { @@ -84,7 +89,7 @@ async function loadScopedRows(input: UserPdfLayoutJobRequest): Promise { + if (parsed) return parsed; + + try { + const json = await getParsedDocumentBlobByKey(parsedJsonKey); + return JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + return null; + } +} + async function updateParseStateForUsers(input: { documentId: string; userIds: string[]; @@ -125,6 +144,23 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise const deadline = Date.now() + FOLLOWER_WAIT_TIMEOUT_MS; while (Date.now() < deadline) { + const reusableParsed = await findReusableParsedPdfResult(input.documentId); + if (reusableParsed) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: reusableParsed.parsedJsonKey, + }); + return; + } + const rows = await loadScopedRows(input); const ready = rows.find(isReadyRow); if (ready) { @@ -132,6 +168,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; await updateParseStateForUsers({ documentId: input.documentId, @@ -142,7 +179,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise return; } - const statuses = rows.map((row) => parseDocumentParseState(row.parseState)); + const statuses = rows.map((row) => normalizeDocumentParseStateForCurrentParserVersion(parseDocumentParseState(row.parseState))); const hasInFlight = statuses.some((state) => state.status === 'pending' || state.status === 'running'); const failedState = statuses.find((state) => state.status === 'failed'); if (failedState && !hasInFlight) { @@ -151,6 +188,7 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise progress: null, updatedAt: Date.now(), ...(failedState.error ? { error: failedState.error } : {}), + ...(failedState.parserVersion ? { parserVersion: failedState.parserVersion } : {}), }; await updateParseStateForUsers({ documentId: input.documentId, @@ -181,12 +219,30 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise // Non-force jobs can short-circuit by reusing existing ready output from // any row in the same document scope. if (!input.forceToken?.trim()) { + const reusableParsed = await findReusableParsedPdfResult(input.documentId); + if (reusableParsed) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: reusableParsed.parsedJsonKey, + }); + return; + } + const ready = scopedRows.find(isReadyRow); if (ready) { const readyState: DocumentParseState = { status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; await updateParseStateForUsers({ documentId: input.documentId, @@ -205,6 +261,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise status: 'running', progress: null, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, }; const runningState = stringifyDocumentParseState(runningStateData); @@ -279,6 +336,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise status: mappedStatus, progress: snapshot.progress ?? null, updatedAt: now, + parserVersion: PDF_PARSER_VERSION, ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; @@ -305,6 +363,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise phase: progress.phase, }, updatedAt: Date.now(), + parserVersion: PDF_PARSER_VERSION, ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; @@ -327,12 +386,15 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); } + const parsedDoc = await loadParsedDocumentForResult(layout.parsed, parsedJsonKey); + const finalScopedRows = await loadScopedRows(input); const finalUserIds = userIdsFromRows(finalScopedRows); const readyState: DocumentParseState = { status: 'ready', progress: null, updatedAt: Date.now(), + parserVersion: resolveParsedPdfParserVersion(parsedDoc), }; const readyUserIds = finalUserIds.length > 0 ? finalUserIds : cohortUserIds; await updateParseStateForUsers({ @@ -385,6 +447,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise progress: null, updatedAt: Date.now(), error: message, + parserVersion: PDF_PARSER_VERSION, }; const failedUserIds = scopedUserIds.length > 0 ? scopedUserIds : [input.userId]; await updateParseStateForUsers({ diff --git a/tests/helpers.ts b/tests/helpers.ts index cfc9472..3a7835d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -188,10 +188,11 @@ async function dismissOnboardingModals(page: Page): Promise { * Wait for the play button to be clickable and click it */ export async function waitAndClickPlay(page: Page) { - // Wait for play button selector without disabled attribute - await expect(page.getByRole('button', { name: 'Play' })).toBeVisible(); + const playButton = page.getByRole('button', { name: 'Play' }); + await expect(playButton).toBeVisible(); + await expect(playButton).toBeEnabled({ timeout: 15000 }); // Play the TTS by clicking the button - await page.getByRole('button', { name: 'Play' }).click(); + await playButton.click(); // Use resilient processing transition helper (tolerates fast completion) await expectProcessingTransition(page); } diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index b642b20..ea193f8 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -115,10 +115,12 @@ test.describe('EPUB resize pauses TTS', () => { await page.waitForTimeout(750); // After resize, playback should have paused (Play button visible) - await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 15000 }); + const playButton = page.getByRole('button', { name: 'Play' }); + await expect(playButton).toBeVisible({ timeout: 15000 }); + await expect(playButton).toBeEnabled({ timeout: 15000 }); // Resume playback and ensure processing -> playing - await page.getByRole('button', { name: 'Play' }).click(); + await playButton.click(); await expectProcessingTransition(page); }); }); diff --git a/tests/unit/pdf-op-key.vitest.spec.ts b/tests/unit/pdf-op-key.vitest.spec.ts new file mode 100644 index 0000000..fbe746e --- /dev/null +++ b/tests/unit/pdf-op-key.vitest.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import { buildPdfOpKey } from '../../src/lib/server/compute/worker'; + +describe('pdf worker op key', () => { + test('includes parser version in the reuse boundary', () => { + expect(buildPdfOpKey({ + documentId: 'doc-1', + namespace: null, + documentObjectKey: 'documents/doc-1.pdf', + })).toBe(`pdf_layout|v1|${PDF_PARSER_VERSION}|doc-1||documents/doc-1.pdf|`); + }); +}); diff --git a/tests/unit/pdf-parse-state.vitest.spec.ts b/tests/unit/pdf-parse-state.vitest.spec.ts new file mode 100644 index 0000000..aa3384b --- /dev/null +++ b/tests/unit/pdf-parse-state.vitest.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; +import { + normalizeDocumentParseStateForCurrentParserVersion, + parseDocumentParseState, + stringifyDocumentParseState, +} from '../../src/lib/server/documents/parse-state'; + +describe('document parse state parser-version handling', () => { + test('preserves parserVersion through stringify/parse', () => { + const state = parseDocumentParseState(stringifyDocumentParseState({ + status: 'ready', + progress: null, + updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, + })); + + expect(state).toEqual({ + status: 'ready', + progress: null, + updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, + }); + }); + + test('invalidates ready and inflight states from older parser versions', () => { + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: 'old-parser', + opId: 'op-old', + }, 2222)).toEqual({ + status: 'pending', + progress: null, + updatedAt: 2222, + }); + + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'running', + progress: { + totalPages: 10, + pagesParsed: 3, + currentPage: 4, + phase: 'infer', + }, + updatedAt: 1111, + parserVersion: 'old-parser', + opId: 'op-old', + }, 3333)).toEqual({ + status: 'pending', + progress: null, + updatedAt: 3333, + }); + }); + + test('keeps failed states and current-version ready states intact', () => { + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'failed', + progress: null, + updatedAt: 1111, + error: 'no text layer', + parserVersion: 'old-parser', + })).toEqual({ + status: 'failed', + progress: null, + updatedAt: 1111, + error: 'no text layer', + parserVersion: 'old-parser', + }); + + expect(normalizeDocumentParseStateForCurrentParserVersion({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: PDF_PARSER_VERSION, + })).toEqual({ + status: 'ready', + progress: null, + updatedAt: 1111, + parserVersion: PDF_PARSER_VERSION, + }); + }); +}); diff --git a/tests/unit/worker-parse-state.vitest.spec.ts b/tests/unit/worker-parse-state.vitest.spec.ts index dfdd37e..91563b3 100644 --- a/tests/unit/worker-parse-state.vitest.spec.ts +++ b/tests/unit/worker-parse-state.vitest.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { PDF_PARSER_VERSION } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; import { documentParseStateFromWorkerState, @@ -11,7 +12,7 @@ function makeWorkerState( ): WorkerOperationState { return { opId: 'op-123', - opKey: 'pdf_layout|v1|doc-1', + opKey: `pdf_layout|v1|${PDF_PARSER_VERSION}|doc-1`, kind: 'pdf_layout', jobId: 'job-123', status: 'queued', @@ -41,6 +42,7 @@ describe('worker parse state mapping', () => { status: 'pending', progress: null, updatedAt: 1234, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', }); @@ -66,6 +68,7 @@ describe('worker parse state mapping', () => { phase: 'infer', }, updatedAt: 5678, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', }); @@ -84,6 +87,7 @@ describe('worker parse state mapping', () => { status: 'failed', progress: null, updatedAt: 9999, + parserVersion: PDF_PARSER_VERSION, opId: 'op-123', jobId: 'job-123', error: 'layout model crashed',