From 766c04d08dc41f925643f1034f937096c952d0a5 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 17 May 2026 21:18:51 -0600 Subject: [PATCH 001/137] refactor(pdf): implement ONNX-based Docling layout parsing and block-level TTS for PDFs Migrate PDF parsing to use ONNX Docling layout model for structured block extraction, enabling improved TTS segmentation and chaptering. Add compute backend abstraction for heavy tasks (alignment, layout parsing) with configuration via `OPENREADER_COMPUTE_MODE`. Integrate block-level locators and document settings for per-document PDF parsing options. Update S3 storage for parsed PDF JSON, add migration and schema changes, and extend client and server APIs for parsed data and settings. Remove legacy word highlight flag in favor of compute capability detection. - Add ONNX model download, local/none compute modes, and `onnxruntime-node` dependency - Update PDF viewer and TTS pipeline to use parsed blocks and block-level locators - Add document settings storage and APIs for per-document PDF options - Update S3 storage layout for parsed PDF JSON - Add admin/config/docs updates for new compute and parsing features - Remove obsolete word highlight runtime flag and UI BREAKING CHANGE: PDF parsing and word highlighting now require `OPENREADER_COMPUTE_MODE=local` and ONNX model; document settings and S3 layout updated; legacy word highlight flag removed. --- .env.example | 12 +- Dockerfile | 4 + README.md | 3 +- docs-site/docs/configure/admin-panel.md | 3 +- docs-site/docs/configure/auth.md | 2 +- docs-site/docs/deploy/local-development.md | 10 +- docs-site/docs/deploy/vercel-deployment.md | 8 +- .../docs/reference/environment-variables.md | 49 +- .../postgres/0005_pdf_layout_and_compute.sql | 14 + drizzle/postgres/meta/0005_snapshot.json | 1833 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + .../sqlite/0005_pdf_layout_and_compute.sql | 14 + drizzle/sqlite/meta/0005_snapshot.json | 1695 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + package.json | 2 + pnpm-lock.yaml | 57 + pnpm-workspace.yaml | 9 +- scripts/fetch-models.mjs | 31 + src/app/(app)/pdf/[id]/page.tsx | 41 + src/app/(app)/pdf/[id]/usePdfDocument.ts | 222 +- src/app/api/documents/[id]/parsed/route.ts | 185 ++ src/app/api/documents/[id]/settings/route.ts | 167 ++ src/app/api/documents/route.ts | 33 +- src/app/api/tts/segments/clear/route.ts | 62 +- src/app/api/tts/segments/ensure/route.ts | 22 +- src/app/api/whisper/route.ts | 13 +- src/components/admin/AdminFeaturesPanel.tsx | 8 - src/components/documents/DocumentSettings.tsx | 88 +- src/components/views/PDFViewer.tsx | 211 +- src/contexts/RuntimeConfigContext.tsx | 4 +- src/contexts/TTSContext.tsx | 42 +- src/db/schema.ts | 1 + src/db/schema_postgres.ts | 14 + src/db/schema_sqlite.ts | 14 + src/lib/client/api/documents.ts | 91 + src/lib/client/audiobooks/adapters/pdf.ts | 89 +- src/lib/client/pdf-block-text.ts | 20 + src/lib/client/pdf.ts | 40 +- src/lib/server/admin/settings.ts | 1 - src/lib/server/auth/auth.ts | 4 +- src/lib/server/compute/index.ts | 37 + src/lib/server/compute/local.ts | 21 + src/lib/server/compute/none.ts | 17 + src/lib/server/compute/types.ts | 33 + src/lib/server/documents/blobstore.ts | 84 +- src/lib/server/jobs/parsePdfJob.ts | 92 + src/lib/server/pdf-layout/ensureModel.ts | 99 + .../server/pdf-layout/mergeTextWithRegions.ts | 145 ++ src/lib/server/pdf-layout/model/LICENSE.txt | 3 + src/lib/server/pdf-layout/model/manifest.json | 21 + src/lib/server/pdf-layout/parsePdf.ts | 153 ++ src/lib/server/pdf-layout/renderPage.ts | 142 ++ src/lib/server/pdf-layout/runLayoutModel.ts | 241 +++ .../pdf-layout/stitchCrossPageBlocks.ts | 80 + src/lib/server/pdf-layout/types.ts | 15 + src/lib/server/runtime-config.ts | 11 +- src/lib/server/storage/s3.ts | 4 + src/lib/server/tts/segments-cache.ts | 82 + src/lib/server/tts/segments.ts | 3 + src/lib/shared/document-settings.ts | 76 + src/lib/shared/tts-locator.ts | 11 +- src/lib/shared/tts-segment-plan.ts | 72 +- src/types/client.ts | 2 + src/types/config.ts | 16 +- src/types/document-settings.ts | 18 + src/types/documents.ts | 2 + src/types/parsed-pdf.ts | 45 + tests/unit/pdf-audiobook-adapter.spec.ts | 77 + tests/unit/pdf-build-page-text.spec.ts | 36 + .../unit/pdf-merge-text-with-regions.spec.ts | 23 + .../unit/pdf-stitch-cross-page-blocks.spec.ts | 91 + tests/unit/transfer-user-documents.spec.ts | 2 + 72 files changed, 6623 insertions(+), 263 deletions(-) create mode 100644 drizzle/postgres/0005_pdf_layout_and_compute.sql create mode 100644 drizzle/postgres/meta/0005_snapshot.json create mode 100644 drizzle/sqlite/0005_pdf_layout_and_compute.sql create mode 100644 drizzle/sqlite/meta/0005_snapshot.json create mode 100644 scripts/fetch-models.mjs create mode 100644 src/app/api/documents/[id]/parsed/route.ts create mode 100644 src/app/api/documents/[id]/settings/route.ts create mode 100644 src/lib/client/pdf-block-text.ts create mode 100644 src/lib/server/compute/index.ts create mode 100644 src/lib/server/compute/local.ts create mode 100644 src/lib/server/compute/none.ts create mode 100644 src/lib/server/compute/types.ts create mode 100644 src/lib/server/jobs/parsePdfJob.ts create mode 100644 src/lib/server/pdf-layout/ensureModel.ts create mode 100644 src/lib/server/pdf-layout/mergeTextWithRegions.ts create mode 100644 src/lib/server/pdf-layout/model/LICENSE.txt create mode 100644 src/lib/server/pdf-layout/model/manifest.json create mode 100644 src/lib/server/pdf-layout/parsePdf.ts create mode 100644 src/lib/server/pdf-layout/renderPage.ts create mode 100644 src/lib/server/pdf-layout/runLayoutModel.ts create mode 100644 src/lib/server/pdf-layout/stitchCrossPageBlocks.ts create mode 100644 src/lib/server/pdf-layout/types.ts create mode 100644 src/lib/server/tts/segments-cache.ts create mode 100644 src/lib/shared/document-settings.ts create mode 100644 src/types/document-settings.ts create mode 100644 src/types/parsed-pdf.ts create mode 100644 tests/unit/pdf-audiobook-adapter.spec.ts create mode 100644 tests/unit/pdf-build-page-text.spec.ts create mode 100644 tests/unit/pdf-merge-text-with-regions.spec.ts create mode 100644 tests/unit/pdf-stitch-cross-page-blocks.spec.ts diff --git a/.env.example b/.env.example index 4418f04..8115116 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,17 @@ IMPORT_LIBRARY_DIRS= # (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli +# Heavy compute backend mode for whisper alignment + PDF layout parsing. +# local = run compute in-process (default) +# none = disable both capabilities (good for preview/serverless) +# worker = reserved for future external worker mode (not implemented in v1) +OPENREADER_COMPUTE_MODE=local +# OPENREADER_COMPUTE_WORKER_URL= +# OPENREADER_COMPUTE_WORKER_TOKEN= + +# Optional override for Docling layout model download URL +# OPENREADER_DOCLING_MODEL_URL=https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx + # (Optional) Override ffmpeg binary path used for audiobook processing FFMPEG_BIN= @@ -95,4 +106,3 @@ FFMPEG_BIN= # NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai # NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json # NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true -# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true diff --git a/Dockerfile b/Dockerfile index 30c4ace..a4d6c81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \ cd whisper.cpp && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \ cmake --build build -j +RUN wget -qO /tmp/whisper.cpp-LICENSE.txt "https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/LICENSE" # Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini) # Pin to 4.18 because CI observed upload regressions on 4.19. @@ -71,10 +72,13 @@ COPY --from=app-builder /app ./ COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses # Include SeaweedFS license text for the copied weed binary. COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt +# Include static model notices for runtime-downloaded assets. +COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/docling-layout-LICENSE.txt # Copy the compiled whisper.cpp build output into the runtime image # (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build +COPY --from=whisper-builder /tmp/whisper.cpp-LICENSE.txt /licenses/whisper.cpp-LICENSE.txt # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed diff --git a/README.md b/README.md index f233826..22e0590 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. +- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`). +- 🧱 **Layout-aware PDF parsing** (Docling ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 533d500..e00d207 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -74,11 +74,12 @@ Runtime-editable settings, one row per key: | `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. | | `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. | | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | -| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | +Word-by-word highlighting and PDF layout parsing capability are controlled by `OPENREADER_COMPUTE_MODE` (server env), not an admin runtime flag. + Each row shows a source badge: - **from env** — the value was migrated from the corresponding `NEXT_PUBLIC_*` env var on first boot. Editing it in the UI flips the source to **admin**. diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 630a29e..643823d 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -31,7 +31,7 @@ ADMIN_EMAILS=alice@example.com,bob@example.com Admins see a new **Admin** tab in **Settings** with two sub-tabs: - **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users. -- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, word highlighting, audiobook export, etc.). +- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, audiobook export, etc.). Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference. diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7fc484a..93d144f 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -156,7 +156,7 @@ If you are not on Debian/Ubuntu, install equivalent packages with your distro pa - Arch: use `pacman` (`base-devel cmake curl git tar xz`) :::tip -Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. +Set `OPENREADER_COMPUTE_MODE=local` and `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. ::: @@ -194,6 +194,7 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local # Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. # (Admin panel is unavailable without auth.) # API_BASE/API_KEY seed a shared default provider if you want shared mode. @@ -205,6 +206,7 @@ API_KEY=none ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -219,6 +221,7 @@ AUTH_SECRET= # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -231,6 +234,7 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local USE_EMBEDDED_WEED_MINI=false S3_BUCKET=your-bucket S3_REGION=us-east-1 @@ -256,6 +260,10 @@ If you want each user to enter personal provider credentials, set `restrictUserA For all environment variables, see [Environment Variables](../reference/environment-variables). ::: +:::tip Optional model prefetch +To pre-populate the Docling ONNX model cache before first PDF parse, run `pnpm fetch-models`. +::: + See [Auth](../configure/auth) for app/auth behavior. See [Admin Panel](../configure/admin-panel) for the shared-provider and feature-flag management UI. Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage). diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 88adbee..1f6f2e0 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -35,6 +35,11 @@ BASE_URL=https://your-app.vercel.app AUTH_SECRET=... ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app +# Heavy compute (recommended on Vercel in v1) +# local = requires native binaries/models in-process +# none = disable whisper alignment + PDF layout parsing +OPENREADER_COMPUTE_MODE=none + # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) API_KEY=your_replicate_key # API_BASE only needed for OpenAI-compatible self-hosted providers @@ -58,7 +63,6 @@ After the first successful deploy and admin login, open **Settings → Admin** a - `defaultTtsProvider=replicate` (or your preferred shared slug). - `showAllProviderModels=false` if you want users locked to each provider's default model. - `enableAudiobookExport=true`. - - `enableWordHighlight=false` unless your timestamp stack is configured. ## 3. Legacy first-boot seed (optional) @@ -126,4 +130,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If using word highlighting, verify timestamps are produced and rendered. +4. If you later enable compute locally (`OPENREADER_COMPUTE_MODE=local`), verify word highlighting timestamps on a TTS run. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index ef79788..e023ba8 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,7 +53,11 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing | +| `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `OPENREADER_DOCLING_MODEL_URL` | PDF layout model | Docling HF URL | Override ONNX download URL for `ensureModel()` | +| `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -346,6 +350,38 @@ Multiple library roots for server library import. ## Audio Tooling and Alignment +### OPENREADER_COMPUTE_MODE + +Selects the backend for heavy compute features (word alignment + PDF layout parsing). + +- Default: `local` +- Supported in v1: + - `local`: run compute in-process on the app server + - `none`: disable these features cleanly +- `worker` is reserved for a future external worker backend and currently fails fast at startup if selected + +### OPENREADER_COMPUTE_WORKER_URL + +Reserved for future external compute worker mode. + +- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Leave unset in v1 + +### OPENREADER_COMPUTE_WORKER_TOKEN + +Reserved bearer token for future external compute worker mode. + +- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Leave unset in v1 + +### OPENREADER_DOCLING_MODEL_URL + +Override URL for the Docling ONNX layout model downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx` +- Optional for custom mirrors/air-gapped workflows +- You can also pre-populate the model cache via `pnpm fetch-models` + ### WHISPER_CPP_BIN Absolute path to compiled `whisper.cpp` binary for word-level timestamps. @@ -432,14 +468,3 @@ Controls whether audiobook export UI/actions are shown in the client. - Default: `true` (enabled) - Affects export entry points in PDF/EPUB pages and document settings UI - Runtime key: `enableAudiobookExport` - -### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT - -Controls word-by-word highlighting UI and timestamp-alignment behavior. - -- Default: `true` (enabled) -- Requires working timestamp generation (for example `WHISPER_CPP_BIN`) -- Affects: - - Word-highlight toggles in document settings - - Alignment requests during TTS playback -- Runtime key: `enableWordHighlight` diff --git a/drizzle/postgres/0005_pdf_layout_and_compute.sql b/drizzle/postgres/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..58654dd --- /dev/null +++ b/drizzle/postgres/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE "document_settings" ( + "document_id" text NOT NULL, + "user_id" text NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + CONSTRAINT "document_settings_document_id_user_id_pk" PRIMARY KEY("document_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parse_status" text;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parsed_json_key" text;--> statement-breakpoint +ALTER TABLE "document_settings" ADD CONSTRAINT "document_settings_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_document_settings_user_id" ON "document_settings" USING btree ("user_id"); diff --git a/drizzle/postgres/meta/0005_snapshot.json b/drizzle/postgres/meta/0005_snapshot.json new file mode 100644 index 0000000..9d083c1 --- /dev/null +++ b/drizzle/postgres/meta/0005_snapshot.json @@ -0,0 +1,1833 @@ +{ + "id": "a23bc220-ebf2-43e2-92b6-d765c23c07d4", + "prevId": "abda257a-d6bd-4a44-b069-4ca884890e9e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 266bd66..035979f 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778644075378, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1779041718214, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0005_pdf_layout_and_compute.sql b/drizzle/sqlite/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..8c6f338 --- /dev/null +++ b/drizzle/sqlite/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE `document_settings` ( + `document_id` text NOT NULL, + `user_id` text NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + PRIMARY KEY(`document_id`, `user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_document_settings_user_id` ON `document_settings` (`user_id`);--> statement-breakpoint +ALTER TABLE `documents` ADD `parse_status` text;--> statement-breakpoint +ALTER TABLE `documents` ADD `parsed_json_key` text; diff --git a/drizzle/sqlite/meta/0005_snapshot.json b/drizzle/sqlite/meta/0005_snapshot.json new file mode 100644 index 0000000..01640f2 --- /dev/null +++ b/drizzle/sqlite/meta/0005_snapshot.json @@ -0,0 +1,1695 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb7fa58d-9b33-46a5-81d1-d2cdfb88c8ee", + "prevId": "7465ec8f-ee2c-4bc3-a0ce-eb8c145d5c6c", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index fafa753..3f75e71 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778644075081, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779041717890, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index bd5aeaf..d7fec12 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "generate": "node drizzle/scripts/generate.mjs", + "fetch-models": "node scripts/fetch-models.mjs", "docs:init": "pnpm --dir docs-site install", "docs:dev": "pnpm --dir docs-site start", "docs:build": "pnpm --dir docs-site build", @@ -48,6 +49,7 @@ "jszip": "^3.10.1", "lru-cache": "^11.3.6", "next": "^15.5.18", + "onnxruntime-node": "^1.26.0", "openai": "^6.37.0", "pdfjs-dist": "4.8.69", "pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f76116..c96e8fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: next: specifier: ^15.5.18 version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + onnxruntime-node: + specifier: ^1.26.0 + version: 1.26.0 openai: specifier: ^6.37.0 version: 6.37.0(zod@4.4.3) @@ -1854,6 +1857,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2843,6 +2850,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + global-agent@4.1.3: + resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3206,6 +3217,10 @@ packages: marks-pane@1.0.9: resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + matcher@4.0.0: + resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3488,6 +3503,13 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onnxruntime-common@1.26.0: + resolution: {integrity: sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==} + + onnxruntime-node@1.26.0: + resolution: {integrity: sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==} + os: [win32, darwin, linux] + openai@6.37.0: resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==} hasBin: true @@ -3924,6 +3946,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@8.1.0: + resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} + engines: {node: '>=10'} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -4181,6 +4207,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -5957,6 +5987,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.17: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -7097,6 +7129,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + global-agent@4.1.3: + dependencies: + globalthis: 1.0.4 + matcher: 4.0.0 + semver: 7.8.0 + serialize-error: 8.1.0 + globals@14.0.0: {} globalthis@1.0.4: @@ -7457,6 +7496,10 @@ snapshots: marks-pane@1.0.9: {} + matcher@4.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -7946,6 +7989,14 @@ snapshots: dependencies: wrappy: 1.0.2 + onnxruntime-common@1.26.0: {} + + onnxruntime-node@1.26.0: + dependencies: + adm-zip: 0.5.17 + global-agent: 4.1.3 + onnxruntime-common: 1.26.0 + openai@6.37.0(zod@4.4.3): optionalDependencies: zod: 4.4.3 @@ -8439,6 +8490,10 @@ snapshots: semver@7.8.0: {} + serialize-error@8.1.0: + dependencies: + type-fest: 0.20.2 + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -8814,6 +8869,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.20.2: {} + type@2.7.3: {} typed-array-buffer@1.0.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 225f824..caae007 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,6 @@ packages: - . -strictDepBuilds: false - allowBuilds: '@napi-rs/canvas': true better-sqlite3: true @@ -11,10 +9,13 @@ allowBuilds: es5-ext: false esbuild: false ffmpeg-static: true + onnxruntime-node: true sharp: false unrs-resolver: false overrides: - lodash: ^4.17.23 - '@xmldom/xmldom': ^0.9.8 '@types/localforage': npm:empty-module@0.0.2 + '@xmldom/xmldom': ^0.9.8 + lodash: ^4.17.23 + +strictDepBuilds: false diff --git a/scripts/fetch-models.mjs b/scripts/fetch-models.mjs new file mode 100644 index 0000000..24cd5ac --- /dev/null +++ b/scripts/fetch-models.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const modelDir = path.join(process.cwd(), 'docstore', 'model'); +const modelPath = path.join(modelDir, 'docling-layout-heron.onnx'); +const configPath = path.join(modelDir, 'docling-layout-heron.config.json'); +const licensePath = path.join(modelDir, 'docling-layout-heron.LICENSE.txt'); +const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt'); + +const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; +const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; + +await mkdir(modelDir, { recursive: true }); + +const modelRes = await fetch(modelUrl); +if (!modelRes.ok) { + throw new Error(`Failed to fetch model: ${modelRes.status} ${modelRes.statusText}`); +} +await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer())); + +const configRes = await fetch(configUrl); +if (!configRes.ok) { + throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`); +} +await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer())); + +const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath)); +await writeFile(licensePath, staticLicense); + +console.log(`Saved model to ${modelPath}`); diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 1ecd6aa..c11441b 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -32,6 +32,7 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); + const computeAvailable = useFeatureFlag('computeAvailable'); const { id } = useParams(); const router = useRouter(); const pdfState = usePdfDocument(); @@ -41,6 +42,12 @@ export default function PDFViewerPage() { clearCurrDoc, currDocPage, currDocPages, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter, } = pdfState; @@ -232,6 +239,40 @@ export default function PDFViewerPage() { setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + pdf={{ + computeAvailable, + parseStatus, + parsedOverlayEnabled, + skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], + chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, + onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), + onToggleSkipKind: (kind, enabled) => { + const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + if (enabled) current.add(kind); + else current.delete(kind); + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + pdf: { + ...(documentSettings.pdf ?? { chaptersFromSections: true }), + skipBlockKinds: Array.from(current), + chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, + }, + }); + }, + onToggleChaptersFromSections: (enabled) => { + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + pdf: { + ...(documentSettings.pdf ?? { skipBlockKinds: [] }), + skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], + chaptersFromSections: enabled, + }, + }); + }, + onForceReparse: () => forceReparseParsedPdf(), + }} /> Promise; + parsedOverlayEnabled: boolean; + setParsedOverlayEnabled: (enabled: boolean) => void; + forceReparseParsedPdf: () => Promise; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; @@ -119,18 +139,24 @@ export function usePdfDocument(): PdfDocumentState { const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); + const [parsedDocument, setParsedDocument] = useState(null); + const [parseStatus, setParseStatus] = useState(null); + const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); + const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ pdfDocument, + parsed: parsedDocument ?? undefined, + settings: documentSettings, margins: { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin, + header: documentSettings.pdf?.margins?.header ?? headerMargin, + footer: documentSettings.pdf?.margins?.footer ?? footerMargin, + left: documentSettings.pdf?.margins?.left ?? leftMargin, + right: documentSettings.pdf?.margins?.right ?? rightMargin, }, smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [pdfDocument, parsedDocument, documentSettings, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -144,6 +170,66 @@ export function usePdfDocument(): PdfDocumentState { // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. const docLoadSeqRef = useRef(0); const docLoadAbortRef = useRef(null); + const parsePollAbortRef = useRef(null); + + const fetchParsedDocument = useCallback(async ( + documentId: string, + initialStatus: PdfParseStatus | null, + signal: AbortSignal, + ): Promise => { + // Legacy PDFs may have null parseStatus; treat as pending so opening the + // document backfills parse output via the parsed endpoint polling path. + const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending'; + setParseStatus(effectiveInitialStatus); + if (effectiveInitialStatus === 'unsupported') { + setParsedDocument(null); + return; + } + + const maxAttempts = 25; + const delayMs = 1200; + const retryFailed = effectiveInitialStatus === 'failed'; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (signal.aborted) return; + const result = await getParsedPdfDocument(documentId, { + signal, + retryFailed: retryFailed && attempt === 0, + }); + if (result.status === 'ready') { + setParsedDocument(result.parsed); + setParseStatus('ready'); + return; + } + setParseStatus(result.status); + if (result.status === 'failed' || result.status === 'unsupported') { + setParsedDocument(null); + return; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + }, []); + + const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise => { + try { + const response = await getDocumentSettings(documentId, { signal }); + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + console.warn('Failed to load document settings, using defaults:', error); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); + } + }, []); + + const startParsedPolling = useCallback((documentId: string, initialStatus: PdfParseStatus | null) => { + parsePollAbortRef.current?.abort(); + const controller = new AbortController(); + parsePollAbortRef.current = controller; + void fetchParsedDocument(documentId, initialStatus, controller.signal).finally(() => { + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + }); + }, [fetchParsedDocument]); useEffect(() => { pdfDocumentRef.current = pdfDocument; @@ -189,10 +275,37 @@ export function usePdfDocument(): PdfDocumentState { : null; const margins = { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin + header: documentSettings.pdf?.margins?.header ?? headerMargin, + footer: documentSettings.pdf?.margins?.footer ?? footerMargin, + left: documentSettings.pdf?.margins?.left ?? leftMargin, + right: documentSettings.pdf?.margins?.right ?? rightMargin + }; + + const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => + parsedDocument?.pages.find((page) => page.pageNumber === pageNum); + + const hasUsableParsedBlocks = (page: ParsedPdfPage | undefined): page is ParsedPdfPage => { + if (!page) return false; + const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + return page.blocks.some((block) => !skipKinds.has(block.kind) && block.text.trim().length > 0); + }; + + const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { + const page = pageFromParsed(pageNum); + if (!page) return []; + const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + return page.blocks + .filter((block) => !skipKinds.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: block.fragments[0]?.page ?? pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { @@ -209,7 +322,15 @@ export function usePdfDocument(): PdfDocumentState { return cached; } - const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins); + const parsedPage = pageFromParsed(pageNumber); + const useParsedPage = hasUsableParsedBlocks(parsedPage) ? parsedPage : undefined; + const extracted = await extractTextFromPDF( + currentPdf, + pageNumber, + margins, + useParsedPage, + useParsedPage ? documentSettings.pdf?.skipBlockKinds : undefined, + ); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); @@ -281,12 +402,17 @@ export function usePdfDocument(): PdfDocumentState { if (text !== currDocText || text === '') { setCurrDocText(text); + const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); + const useBlockStructuredPlanning = sourceUnits.length > 0; setTTSText(text, { location: currDocPageNumber, - previousText: prevText, - nextLocation: nextPageNumber, - nextText: nextText, - upcomingLocations: additionalUpcoming, + ...(!useBlockStructuredPlanning ? { + previousText: prevText, + nextLocation: nextPageNumber, + nextText: nextText, + upcomingLocations: additionalUpcoming, + } : {}), + ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { @@ -305,6 +431,8 @@ export function usePdfDocument(): PdfDocumentState { leftMargin, rightMargin, segmentPreloadDepthPages, + parsedDocument, + documentSettings, ]); /** @@ -341,6 +469,8 @@ export function usePdfDocument(): PdfDocumentState { clearTimeout(emptyRetryRef.current.timer); } emptyRetryRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); @@ -348,6 +478,9 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); + setParsedDocument(null); + setParseStatus(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale @@ -355,6 +488,10 @@ export function usePdfDocument(): PdfDocumentState { console.error('Document not found on server'); return; } + if (meta.type === 'pdf') { + startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null); + void fetchDocumentSettings(id, controller.signal); + } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale @@ -376,7 +513,39 @@ export function usePdfDocument(): PdfDocumentState { docLoadAbortRef.current = null; } } - }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]); + }, [ + setCurrDocId, + setCurrDocName, + setCurrDocData, + setCurrDocPages, + setCurrDocText, + setPdfDocument, + fetchDocumentSettings, + startParsedPolling, + ]); + + const updateDocumentSettings = useCallback(async (settings: DocumentSettings): Promise => { + if (!currDocId) return; + setDocumentSettings(settings); + try { + const updated = await putDocumentSettings(currDocId, settings); + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings)); + } catch (error) { + console.warn('Failed to persist document settings:', error); + } + }, [currDocId]); + + const forceReparseParsedPdf = useCallback(async (): Promise => { + if (!currDocId) return; + try { + await forceReparsePdfDocument(currDocId); + setParsedDocument(null); + setParseStatus('pending'); + startParsedPolling(currDocId, 'pending'); + } catch (error) { + console.error('Failed to force PDF reparse:', error); + } + }, [currDocId, startParsedPolling]); /** * Clears the current document state @@ -390,6 +559,8 @@ export function usePdfDocument(): PdfDocumentState { docLoadSeqRef.current += 1; docLoadAbortRef.current?.abort(); docLoadAbortRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; if (emptyRetryRef.current?.timer) { clearTimeout(emptyRetryRef.current.timer); } @@ -400,6 +571,9 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocText(undefined); setCurrDocPages(undefined); setPdfDocument(undefined); + setParsedDocument(null); + setParseStatus(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); @@ -499,6 +673,13 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, highlightPattern, clearHighlights, @@ -518,6 +699,13 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, pdfDocument, createFullAudioBook, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts new file mode 100644 index 0000000..96e281c --- /dev/null +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getParsedDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { + if (!doc || !Array.isArray(doc.pages)) return false; + return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + const retryFailed = req.nextUrl.searchParams.get('retry') === '1'; + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseStatus: documents.parseStatus, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + }>; + + const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (row.parseStatus === 'failed' && retryFailed) { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + } + + if (row.parseStatus !== 'ready') { + if (row.parseStatus === 'pending' || row.parseStatus === 'running' || row.parseStatus === null) { + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + } + return NextResponse.json({ parseStatus: row.parseStatus ?? 'pending' }, { status: 202 }); + } + + try { + const json = await getParsedDocumentBlob(id, testNamespace); + let parsedDoc: ParsedPdfDocument | null = null; + try { + parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + parsedDoc = null; + } + + if (!hasAnyParsedBlocks(parsedDoc)) { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + } + + return new NextResponse(new Uint8Array(json), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + if (isMissingBlobError(error)) { + return NextResponse.json({ parseStatus: 'failed', error: 'Parsed document not found' }, { status: 404 }); + } + throw error; + } + } catch (error) { + console.error('Error reading parsed PDF:', error); + return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseStatus: documents.parseStatus, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + }>; + + const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (row.parseStatus !== 'running') { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + } + + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + + return NextResponse.json( + { parseStatus: row.parseStatus === 'running' ? 'running' : 'pending' }, + { status: 202 }, + ); + } catch (error) { + console.error('Error forcing parsed PDF refresh:', error); + return NextResponse.json({ error: 'Failed to force parsed PDF refresh' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts new file mode 100644 index 0000000..a63d423 --- /dev/null +++ b/src/app/api/documents/[id]/settings/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documentSettings, documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { mergeDocumentSettings } from '@/lib/shared/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; +import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; + +export const dynamic = 'force-dynamic'; + +function serializeForDb(payload: Record): Record | string { + if (process.env.POSTGRES_URL) return payload; + return JSON.stringify(payload); +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + const normalized = coerceTimestampMs(value, nowTimestampMs()); + if (normalized <= 0) return nowTimestampMs(); + return normalized; +} + +function parseStored(value: unknown): DocumentSettings { + if (typeof value === 'string') { + try { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, JSON.parse(value)); + } catch { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, null); + } + } + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, value); +} + +async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise< + | { ownerUserId: string; allowedUserIds: string[] } + | Response +> { + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = await db + .select({ userId: documents.userId }) + .from(documents) + .where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds))) + .limit(1); + + if (!rows[0]) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return { + ownerUserId: rows[0].userId, + allowedUserIds, + }; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const rows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const row = rows[0]; + const settings = parseStored(row?.dataJson); + + return NextResponse.json({ + settings, + clientUpdatedAtMs: Number(row?.clientUpdatedAtMs ?? 0), + hasStoredSettings: Boolean(row), + }); + } catch (error) { + console.error('Error loading document settings:', error); + return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as { settings?: unknown; clientUpdatedAtMs?: unknown } | null; + const incoming = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, body?.settings ?? null); + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + const existingRows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const existing = existingRows[0]; + const existingUpdatedAt = Number(existing?.clientUpdatedAtMs ?? 0); + if (existing && clientUpdatedAtMs < existingUpdatedAt) { + return NextResponse.json({ + settings: parseStored(existing.dataJson), + clientUpdatedAtMs: existingUpdatedAt, + applied: false, + }); + } + + const updatedAt = nowTimestampMs(); + const payload = serializeForDb(incoming as unknown as Record); + + await db + .insert(documentSettings) + .values({ + documentId, + userId: scope.ownerUserId, + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [documentSettings.documentId, documentSettings.userId], + set: { + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + settings: incoming, + clientUpdatedAtMs, + applied: true, + }); + } catch (error) { + console.error('Error updating document settings:', error); + return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 38eb3b9..af1e835 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -9,6 +9,7 @@ import { deleteDocumentPreviewRows, enqueueDocumentPreview, } from '@/lib/server/documents/previews'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; @@ -129,6 +130,8 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseStatus: doc.type === 'pdf' ? 'pending' : null, + parsedJsonKey: null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -138,6 +141,8 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseStatus: doc.type === 'pdf' ? 'pending' : null, + parsedJsonKey: null, }, }); @@ -160,6 +165,14 @@ export async function POST(req: NextRequest) { ).catch((error) => { console.error(`Failed to enqueue preview for document ${doc.id}:`, error); }); + + if (doc.type === 'pdf') { + enqueueParsePdfJob({ + documentId: doc.id, + userId: storageUserId, + namespace: testNamespace, + }); + } } return NextResponse.json({ success: true, stored }); @@ -206,9 +219,25 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parsedJsonKey: string | null; }>; - const results: BaseDocument[] = rows.map((doc) => { + const preferredById = new Map(); + for (const row of rows) { + const existing = preferredById.get(row.id); + if (!existing) { + preferredById.set(row.id, row); + continue; + } + const isRowPrimary = row.userId === storageUserId; + const isExistingPrimary = existing.userId === storageUserId; + if (isRowPrimary && !isExistingPrimary) { + preferredById.set(row.id, row); + } + } + + const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); return { id: doc.id, @@ -216,6 +245,8 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, + parseStatus: doc.parseStatus, + parsedJsonKey: doc.parsedJsonKey, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; }); diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index 89a07ec..642c62c 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -1,9 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -25,59 +22,16 @@ export async function POST(request: NextRequest) { const scope = await resolveSegmentDocumentScope(request, parsed.documentId); if (scope instanceof Response) return scope; - const rows = (await db - .select({ - segmentId: ttsSegmentVariants.segmentId, - audioKey: ttsSegmentVariants.audioKey, - }) - .from(ttsSegmentVariants) - .innerJoin(ttsSegmentEntries, and( - eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), - eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), - )) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - ))) as Array<{ segmentId: string; audioKey: string | null }>; - - await db - .delete(ttsSegmentEntries) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - )); - - const audioKeys = rows - .map((row) => row.audioKey) - .filter((key): key is string => Boolean(key)); - const uniqueAudioKeys = Array.from(new Set(audioKeys)); - - let deletedAudioObjects = 0; - let warning: string | undefined; - if (uniqueAudioKeys.length > 0) { - try { - deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); - if (deletedAudioObjects < uniqueAudioKeys.length) { - warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; - } - } catch (error) { - warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; - console.warn('Failed clearing some TTS segment audio objects:', { - documentId: parsed.documentId, - userId: scope.storageUserId, - error: warning, - }); - } - } + const cleared = await clearTtsSegmentCache({ + userId: scope.storageUserId, + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + readerType: scope.readerType, + }); return NextResponse.json({ documentId: parsed.documentId, - deletedSegments: rows.length, - requestedAudioObjects: uniqueAudioKeys.length, - deletedAudioObjects, - ...(warning ? { warning } : {}), + ...cleared, }); } catch (error) { console.error('Error clearing TTS segment cache:', error); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index e6e4789..ad5bffa 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -30,7 +30,7 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; -import { alignAudioWithText } from '@/lib/server/whisper/alignment'; +import { getCompute } from '@/lib/server/compute'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; @@ -374,12 +374,10 @@ export async function POST(request: NextRequest) { try { const audioBuffer = await getTtsSegmentAudioObject(existing.audioKey); const whisperBytes = Uint8Array.from(audioBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); + const aligned = (await getCompute().alignWords({ + audioBuffer: whisperBytes.buffer, + text: segment.text, + })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; if (alignment) { @@ -527,12 +525,10 @@ export async function POST(request: NextRequest) { let alignment: TTSSegmentManifestItem['alignment'] = null; try { const whisperBytes = Uint8Array.from(persistedBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); + const aligned = (await getCompute().alignWords({ + audioBuffer: whisperBytes.buffer, + text: segment.text, + })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; } catch (alignError) { console.warn('Whisper alignment unavailable for segment; continuing without word highlights.', { diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index 4c73ff3..5bfe152 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -1,11 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import type { TTSSentenceAlignment } from '@/types/tts'; import { auth } from '@/lib/server/auth/auth'; -import { - alignAudioWithText, - makeWhisperCacheKey, - type WhisperRequestBody, -} from '@/lib/server/whisper/alignment'; +import { makeWhisperCacheKey, type WhisperRequestBody } from '@/lib/server/whisper/alignment'; +import { getCompute } from '@/lib/server/compute'; export const runtime = 'nodejs'; @@ -29,12 +26,12 @@ export async function POST(req: NextRequest) { const cacheKey = makeWhisperCacheKey(body); const audioBuffer = new Uint8Array(audio).buffer; - const alignments: TTSSentenceAlignment[] = await alignAudioWithText( + const alignments: TTSSentenceAlignment[] = (await getCompute().alignWords({ audioBuffer, text, cacheKey, - { engine: 'whisper.cpp', lang } - ); + lang, + })).alignments; return NextResponse.json({ alignments }, { status: 200 }); } catch (error) { diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 8986daa..41aca85 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -303,14 +303,6 @@ export function AdminFeaturesPanel() { right={renderSource('enableUserSignups')} variant="flat" /> - updateDraft('enableWordHighlight', checked)} - right={renderSource('enableWordHighlight')} - variant="flat" - /> = [ + { kind: 'page-header', label: 'Page headers' }, + { kind: 'page-footer', label: 'Page footers' }, + { kind: 'footnote', label: 'Footnotes' }, + { kind: 'caption', label: 'Captions' }, +]; const viewTypeTextMapping = [ { id: 'single', name: 'Single Page' }, @@ -71,13 +80,25 @@ function RangeSetting({ ); } -export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { +export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { isOpen: boolean, setIsOpen: (isOpen: boolean) => void, epub?: boolean, - html?: boolean + html?: boolean, + pdf?: { + computeAvailable: boolean; + parseStatus: PdfParseStatus | null; + parsedOverlayEnabled: boolean; + skipBlockKinds: ParsedPdfBlockKind[]; + chaptersFromSections: boolean; + onToggleOverlay: (enabled: boolean) => void; + onToggleSkipKind: (kind: ParsedPdfBlockKind, enabled: boolean) => void; + onToggleChaptersFromSections: (enabled: boolean) => void; + onForceReparse: () => void; + } }) { - const canWordHighlight = useFeatureFlag('enableWordHighlight'); + const computeAvailable = useFeatureFlag('computeAvailable'); + const canWordHighlight = computeAvailable; const { viewType, skipBlank, @@ -315,7 +336,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('pdfWordHighlightEnabled', checked)} @@ -346,7 +367,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('epubWordHighlightEnabled', checked)} @@ -370,7 +391,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('htmlWordHighlightEnabled', checked)} @@ -378,6 +399,59 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> )} + + {!epub && !html && pdf && ( +
+
+ Parse status: {pdf.parseStatus ?? 'pending'} + +
+ + +
+

Skip while reading aloud

+ {PDF_SKIP_KIND_OPTIONS.map((option) => { + const checked = pdf.skipBlockKinds.includes(option.kind); + return ( + pdf.onToggleSkipKind(option.kind, enabled)} + variant="flat" + /> + ); + })} +
+
+ )} ); diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 01c980c..1cc8e1a 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -10,6 +10,7 @@ import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; import type { PdfDocumentState } from '@/app/(app)/pdf/[id]/usePdfDocument'; +import type { ParsedPdfBlock, ParsedPdfPage } from '@/types/parsed-pdf'; interface PDFViewerProps { zoomLevel: number; @@ -25,6 +26,8 @@ interface PDFViewerProps { | 'currDocPages' | 'currDocText' | 'currDocPage' + | 'parsedDocument' + | 'parsedOverlayEnabled' >; } @@ -67,6 +70,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { currDocPages, currDocText, currDocPage, + parsedDocument, + parsedOverlayEnabled, } = pdfState; // IMPORTANT: @@ -299,6 +304,107 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return scaleRef.current; }, [calculateScale]); + const parsedPageByNumber = useMemo(() => { + const map = new Map(); + for (const page of parsedDocument?.pages ?? []) { + map.set(page.pageNumber, page); + } + return map; + }, [parsedDocument]); + + const parsedOverlayByPage = useMemo(() => { + const map = new Map>(); + + const seen = new Set(); + for (const page of parsedDocument?.pages ?? []) { + for (const block of page.blocks) { + for (let fragmentIndex = 0; fragmentIndex < block.fragments.length; fragmentIndex += 1) { + const fragment = block.fragments[fragmentIndex]; + if (!fragment) continue; + const key = `${block.id}:${fragment.page}:${fragment.readingOrder}`; + if (seen.has(key)) continue; + seen.add(key); + + const list = map.get(fragment.page) ?? []; + list.push({ + block, + fragment, + isContinuation: fragmentIndex > 0, + }); + map.set(fragment.page, list); + } + } + } + + for (const list of map.values()) { + list.sort((a, b) => { + if (a.fragment.readingOrder !== b.fragment.readingOrder) { + return a.fragment.readingOrder - b.fragment.readingOrder; + } + return a.block.id.localeCompare(b.block.id); + }); + } + + return map; + }, [parsedDocument]); + + const colorForKind = (kind: ParsedPdfBlock['kind']): string => { + switch (kind) { + case 'section-header': return 'rgba(34,197,94,0.20)'; + case 'title': return 'rgba(16,185,129,0.20)'; + case 'caption': return 'rgba(245,158,11,0.20)'; + case 'table': return 'rgba(59,130,246,0.20)'; + case 'picture': return 'rgba(139,92,246,0.20)'; + case 'page-header': + case 'page-footer': + case 'footnote': return 'rgba(239,68,68,0.20)'; + default: return 'rgba(14,165,233,0.18)'; + } + }; + + const renderParsedOverlay = (pageNumber: number) => { + if (!parsedOverlayEnabled) return null; + const parsedPage = parsedPageByNumber.get(pageNumber); + if (!parsedPage) return null; + const overlayEntries = parsedOverlayByPage.get(pageNumber) ?? []; + return ( +
+ {overlayEntries.map(({ block, fragment, isContinuation }) => { + const [x0, y0, x1, y1] = fragment.bbox; + const width = parsedPage.width || 1; + const height = parsedPage.height || 1; + const leftPct = (x0 / width) * 100; + const boxWidthPct = Math.max(0, ((x1 - x0) / width) * 100); + // Parsed model bboxes are top-left based; use y0 directly. + const topPct = (y0 / height) * 100; + const boxHeightPct = Math.max(0, ((y1 - y0) / height) * 100); + + return ( +
+ + {isContinuation ? `${block.kind} (cont)` : block.kind} + +
+ ); + })} +
+ ); + }; + return (
{currDocPages && [...Array(currDocPages)].map((_, i) => ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(i + 1)} +
))}
) : ( // Single/Dual page mode
{currDocPages && leftPage > 0 && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(leftPage)} +
)} {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(rightPage)} +
)}
)} diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index 16a665c..e1e3991 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -18,11 +18,11 @@ export interface RuntimeConfig { enableUserSignups: boolean; restrictUserApiKeys: boolean; enableTtsProvidersTab: boolean; - enableWordHighlight: boolean; enableAudiobookExport: boolean; enableDocxConversion: boolean; enableDestructiveDeleteActions: boolean; showAllProviderModels: boolean; + computeAvailable: boolean; } const RUNTIME_DEFAULTS: RuntimeConfig = { @@ -32,11 +32,11 @@ const RUNTIME_DEFAULTS: RuntimeConfig = { enableUserSignups: true, restrictUserApiKeys: true, enableTtsProvidersTab: true, - enableWordHighlight: true, enableAudiobookExport: true, enableDocxConversion: true, enableDestructiveDeleteActions: true, showAllProviderModels: true, + computeAvailable: true, }; declare global { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fa4b428..8bbc362 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -159,6 +159,7 @@ interface TTSContextType extends TTSPlaybackState { interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; + sourceUnits?: CanonicalTtsSourceUnit[]; previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; @@ -218,8 +219,8 @@ const wordHighlightFeatureEnabled = (() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return true; - return typeof injected.enableWordHighlight === 'boolean' - ? injected.enableWordHighlight + return typeof injected.computeAvailable === 'boolean' + ? injected.computeAvailable : true; })(); @@ -292,6 +293,11 @@ const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => { if (typeof locator.location === 'string' && locator.location) { return normalizeLocationKey(locator.location); } + if (locator.readerType === 'pdf') { + const page = Number(locator.page || 1); + const block = typeof locator.blockId === 'string' && locator.blockId ? locator.blockId : ''; + return normalizeLocationKey(`pdf:${page}:${block}`); + } return normalizeLocationKey(Number(locator.page || 1)); }; @@ -1124,6 +1130,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? normalizedOptions.location : currDocPage; const resolvedLocationKey = normalizeLocationKey(resolvedLocation); + const currentUnits = normalizedOptions.sourceUnits && normalizedOptions.sourceUnits.length > 0 + ? normalizedOptions.sourceUnits + : null; // Keep currDocPage aligned with whatever the caller declared as the viewport's // location. This is the canonical entry point for "the rendered page now shows @@ -1142,6 +1151,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement text, locator: locatorForLocation(resolvedLocation, activeReaderType), }; + const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource]; + const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey)); const contextSourceUnits: CanonicalTtsSourceUnit[] = []; if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { @@ -1156,7 +1167,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : null, }); } - contextSourceUnits.push(currentSource); + contextSourceUnits.push(...effectiveCurrentUnits); const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; plannedSegmentsByLocationRef.current.clear(); @@ -1191,14 +1202,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }); const currentSegments = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey) - : planCanonicalTtsSegments([currentSource], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - }).segments; + ? plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)) + : effectiveCurrentUnits.flatMap((unit) => + planCanonicalTtsSegments([unit], { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, + }).segments); const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { @@ -1208,13 +1222,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }).segments; if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } } - currentSourceUnitRef.current = currentSource; + currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; currentSourceContextUnitsRef.current = contextSourceUnits; if (handleBlankSection(newSentences.join(' '))) return; @@ -1288,7 +1303,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); - if (smartSentenceSplitting && !isEPUB && normalizedOptions.nextLocation !== undefined) { + if ( + smartSentenceSplitting + && !isEPUB + && normalizedOptions.nextLocation !== undefined + && effectiveCurrentUnits.length === 1 + ) { const spanningIndex = currentSegments.findIndex((segment) => segment.spansSourceBoundary && segment.startAnchor.sourceKey === currentSourceKey diff --git a/src/db/schema.ts b/src/db/schema.ts index 6b3bab9..b295a06 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -11,6 +11,7 @@ export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 9778aae..d87ef26 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -12,6 +12,8 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), + parseStatus: text('parse_status'), + parsedJsonKey: text('parsed_json_key'), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = pgTable('user_preferences', { updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }); +export const documentSettings = pgTable('document_settings', { + documentId: text('document_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + dataJson: jsonb('data_json').notNull().default({}), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = pgTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 920a42d..1697a18 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -12,6 +12,8 @@ export const documents = sqliteTable('documents', { size: integer('size').notNull(), lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), + parseStatus: text('parse_status'), + parsedJsonKey: text('parsed_json_key'), createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = sqliteTable('user_preferences', { updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }); +export const documentSettings = sqliteTable('document_settings', { + documentId: text('document_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + dataJson: text('data_json').notNull().default('{}'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = sqliteTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 5cd5697..829a392 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -1,5 +1,7 @@ import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; export type UploadSource = { id: string; @@ -76,6 +78,95 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort return docs[0] ?? null; } +export async function getParsedPdfDocument( + id: string, + options?: { signal?: AbortSignal; retryFailed?: boolean }, +): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' | 'unsupported' }> { + const query = options?.retryFailed ? '?retry=1' : ''; + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { + signal: options?.signal, + cache: 'no-store', + }); + + if (res.status === 202) { + const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; + const parseStatus = data?.parseStatus; + if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed' || parseStatus === 'unsupported') { + return { status: parseStatus }; + } + return { status: 'pending' }; + } + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load parsed PDF'); + } + + const parsed = (await res.json()) as ParsedPdfDocument; + return { status: 'ready', parsed }; +} + +export async function forceReparsePdfDocument( + id: string, + options?: { signal?: AbortSignal }, +): Promise<'pending' | 'running'> { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed`, { + method: 'POST', + signal: options?.signal, + cache: 'no-store', + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to force PDF reparse'); + } + + const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; + return data?.parseStatus === 'running' ? 'running' : 'pending'; +} + +type DocumentSettingsResponse = { + settings: DocumentSettings; + clientUpdatedAtMs: number; + hasStoredSettings?: boolean; +}; + +export async function getDocumentSettings( + id: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + signal: options?.signal, + cache: 'no-store', + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load document settings'); + } + return (await res.json()) as DocumentSettingsResponse; +} + +export async function putDocumentSettings( + id: string, + settings: DocumentSettings, + options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings, + clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), + }), + signal: options?.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update document settings'); + } + return (await res.json()) as DocumentSettingsResponse & { applied: boolean }; +} + export async function uploadDocumentSources(sources: UploadSource[], options?: UploadOptions): Promise { if (sources.length === 0) return []; diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 85300f5..4b52581 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -1,11 +1,15 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { extractTextFromPDF } from '@/lib/client/pdf'; import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline'; import { normalizeTextForTts } from '@/lib/shared/nlp'; +import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { pdfDocument?: PDFDocumentProxy; + parsed?: ParsedPdfDocument; + settings?: DocumentSettings; margins: { header: number; footer: number; @@ -16,16 +20,99 @@ interface PdfAudiobookAdapterOptions { maxBlockLength?: number; } +function chapterTextFromBlocks( + blocks: ParsedPdfBlock[], + smartSentenceSplitting: boolean, + maxBlockLength?: number, +): string { + const text = blocks + .map((block) => block.text.trim()) + .filter(Boolean) + .join('\n\n'); + if (!text) return ''; + return smartSentenceSplitting ? normalizeTextForTts(text, { maxBlockLength }) : text; +} + +function prepareParsedChapters({ + parsed, + settings, + smartSentenceSplitting, + maxBlockLength, +}: { + parsed: ParsedPdfDocument; + settings: DocumentSettings; + smartSentenceSplitting: boolean; + maxBlockLength?: number; +}): PreparedAudiobookChapter[] { + const skip = new Set(settings.pdf?.skipBlockKinds ?? DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? []); + const allBlocks = parsed.pages + .flatMap((page) => page.blocks) + .filter((block) => !skip.has(block.kind)); + if (!allBlocks.length) return []; + + const chaptersFromSections = settings.pdf?.chaptersFromSections ?? true; + if (!chaptersFromSections) { + const text = chapterTextFromBlocks(allBlocks, smartSentenceSplitting, maxBlockLength); + return text ? [{ index: 0, title: 'Document', text }] : []; + } + + const chapters: PreparedAudiobookChapter[] = []; + let currentTitle = 'Introduction'; + let currentBlocks: ParsedPdfBlock[] = []; + + const flush = () => { + if (!currentBlocks.length) return; + const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); + if (text) { + chapters.push({ + index: chapters.length, + title: currentTitle, + text, + }); + } + currentBlocks = []; + }; + + for (const block of allBlocks) { + if (block.kind === 'section-header') { + flush(); + currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`; + currentBlocks.push(block); + continue; + } + currentBlocks.push(block); + } + flush(); + + return chapters; +} + async function extractPreparedPdfChapters({ pdfDocument, + parsed, + settings = DEFAULT_DOCUMENT_SETTINGS, margins, smartSentenceSplitting, maxBlockLength, }: PdfAudiobookAdapterOptions): Promise { + if (parsed) { + const parsedChapters = prepareParsedChapters({ + parsed, + settings, + smartSentenceSplitting, + maxBlockLength, + }); + if (parsedChapters.length > 0) { + return parsedChapters; + } + } + if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const { extractTextFromPDF } = await import('@/lib/client/pdf'); + const chapters: PreparedAudiobookChapter[] = []; for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins); diff --git a/src/lib/client/pdf-block-text.ts b/src/lib/client/pdf-block-text.ts new file mode 100644 index 0000000..ac793ac --- /dev/null +++ b/src/lib/client/pdf-block-text.ts @@ -0,0 +1,20 @@ +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +export function buildPageTextFromBlocks( + page: ParsedPdfPage, + skipKinds: ParsedPdfBlockKind[] = [], +): string { + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .sort((a, b) => { + const aOrder = a.fragments[0]?.readingOrder ?? 0; + const bOrder = b.fragments[0]?.readingOrder ?? 0; + return aOrder - bOrder; + }) + .map((block) => block.text.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index 6c09d24..8ecc635 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -3,6 +3,8 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; +import type { ParsedPdfPage, ParsedPdfBlockKind } from '@/types/parsed-pdf'; +import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; import { CmpStr } from 'cmpstr'; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); @@ -80,18 +82,16 @@ function runHighlightTokenMatch( function shouldUseLegacyBuild() { try { if (typeof window === 'undefined') return false; - + const ua = window.navigator.userAgent; const isSafari = /^((?!chrome|android).)*safari/i.test(ua); - - console.log(isSafari ? 'Running on Safari' : 'Not running on Safari'); + if (!isSafari) return false; - + // Extract Safari version - matches "Version/18" format const match = ua.match(/Version\/(\d+)/i); - console.log('Safari version:', match); if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe - + const version = parseInt(match[1]); return version < 18; // Use legacy build for Safari versions equal or below 18 } catch (e) { @@ -106,10 +106,9 @@ function initPDFWorker() { if (typeof window !== 'undefined') { const useLegacy = shouldUseLegacyBuild(); // Use local worker file instead of unpkg - const workerSrc = useLegacy + const workerSrc = useLegacy ? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href : new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href; - console.log('Setting PDF worker to:', workerSrc); pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; pdfjs.GlobalWorkerOptions.workerPort = null; } @@ -192,35 +191,38 @@ const normalizeWordForMatch = (text: string): string => export async function extractTextFromPDF( pdf: PDFDocumentProxy, pageNumber: number, - margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 } + margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, + parsed?: ParsedPdfPage, + skipKinds?: ParsedPdfBlockKind[], ): Promise { try { - // Log pdf worker version - //console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc); + if (parsed) { + return buildPageTextFromBlocks(parsed, skipKinds ?? []); + } const page = await pdf.getPage(pageNumber); const textContent = await page.getTextContent(); - + const viewport = page.getViewport({ scale: 1.0 }); const pageHeight = viewport.height; const pageWidth = viewport.width; const textItems = textContent.items.filter((item): item is TextItem => { if (!('str' in item && 'transform' in item)) return false; - + const [scaleX, skewX, skewY, scaleY, x, y] = item.transform; - + // Basic text filtering if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false; if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false; if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; - + // Calculate margins in PDF coordinate space (y=0 is at bottom) const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based const leftX = pageWidth * margins.left; const rightX = pageWidth * (1 - margins.right); - + // Check margins - remember y=0 is at bottom of page in PDF coordinates if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area return false; @@ -230,15 +232,13 @@ export async function extractTextFromPDF( if (x < leftX || x > rightX) { return false; } - + // Sanity check for coordinates if (x < 0 || x > pageWidth) return false; - + return item.str.trim().length > 0; }); - //console.log('Filtered text items:', textItems); - const tolerance = 2; const lines: TextItem[][] = []; let currentLine: TextItem[] = []; diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 8e55824..598d46f 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -82,7 +82,6 @@ export const RUNTIME_CONFIG_SCHEMA = { // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. enableTtsProvidersTab: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB'), - enableWordHighlight: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT'), enableAudiobookExport: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT'), enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'), enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 7839e42..9cf8d26 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -139,7 +139,6 @@ const createAuth = () => betterAuth({ }, }, plugins: [ - nextCookies(), // Enable Next.js cookie handling ...(isAnonymousAuthSessionsEnabled() ? [ anonymous({ @@ -219,6 +218,9 @@ const createAuth = () => betterAuth({ }), ] : []), + // Better Auth requires cookie integration plugins last so post-hooks can + // still append Set-Cookie headers that are forwarded to Next.js. + nextCookies(), ], }); diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts new file mode 100644 index 0000000..f0700bb --- /dev/null +++ b/src/lib/server/compute/index.ts @@ -0,0 +1,37 @@ +import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; +import { LocalComputeBackend } from '@/lib/server/compute/local'; +import { NoneComputeBackend } from '@/lib/server/compute/none'; + +let backend: ComputeBackend | null = null; + +function readMode(): ComputeMode { + const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase(); + if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; + return 'local'; +} + +function createBackend(): ComputeBackend { + const mode = readMode(); + if (mode === 'none') return new NoneComputeBackend(); + if (mode === 'worker') { + throw new Error( + 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + ); + } + return new LocalComputeBackend(); +} + +export function getCompute(): ComputeBackend { + if (!backend) backend = createBackend(); + return backend; +} + +export function isComputeAvailable(): boolean { + const mode = readMode(); + if (mode === 'worker') { + throw new Error( + 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + ); + } + return mode !== 'none'; +} diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts new file mode 100644 index 0000000..2f95a98 --- /dev/null +++ b/src/lib/server/compute/local.ts @@ -0,0 +1,21 @@ +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { alignAudioWithText } from '@/lib/server/whisper/alignment'; +import { parsePdf } from '@/lib/server/pdf-layout/parsePdf'; + +export class LocalComputeBackend implements ComputeBackend { + readonly mode = 'local' as const; + + async alignWords(input: WhisperAlignInput): Promise { + const alignments = await alignAudioWithText( + input.audioBuffer, + input.text, + input.cacheKey, + { engine: 'whisper.cpp', lang: input.lang }, + ); + return { alignments }; + } + + async parsePdfLayout(input: PdfLayoutInput) { + return parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes }); + } +} diff --git a/src/lib/server/compute/none.ts b/src/lib/server/compute/none.ts new file mode 100644 index 0000000..a2f6695 --- /dev/null +++ b/src/lib/server/compute/none.ts @@ -0,0 +1,17 @@ +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { UnsupportedComputeError } from '@/lib/server/compute/types'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export class NoneComputeBackend implements ComputeBackend { + readonly mode = 'none' as const; + + async alignWords(input: WhisperAlignInput): Promise { + void input; + throw new UnsupportedComputeError('Word alignment is unavailable: OPENREADER_COMPUTE_MODE=none'); + } + + async parsePdfLayout(input: PdfLayoutInput): Promise { + void input; + throw new UnsupportedComputeError('PDF layout parsing is unavailable: OPENREADER_COMPUTE_MODE=none'); + } +} diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts new file mode 100644 index 0000000..3a411eb --- /dev/null +++ b/src/lib/server/compute/types.ts @@ -0,0 +1,33 @@ +import type { TTSAudioBuffer, TTSSentenceAlignment } from '@/types/tts'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export type ComputeMode = 'local' | 'worker' | 'none'; + +export interface WhisperAlignInput { + audioBuffer: TTSAudioBuffer; + text: string; + cacheKey?: string; + lang?: string; +} + +export interface WhisperAlignResult { + alignments: TTSSentenceAlignment[]; +} + +export interface PdfLayoutInput { + documentId: string; + pdfBytes: ArrayBuffer; +} + +export interface ComputeBackend { + mode: ComputeMode; + alignWords(input: WhisperAlignInput): Promise; + parsePdfLayout(input: PdfLayoutInput): Promise; +} + +export class UnsupportedComputeError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedComputeError'; + } +} diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index d99f444..d94d9f2 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -80,11 +80,55 @@ export function documentKey(id: string, namespace: string | null): string { return `${cfg.prefix}/documents_v1/${nsSegment}${id}`; } +export function documentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/parsed_v1/${nsSegment}${id}.json`; +} + +function legacyDocumentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/${nsSegment}${id}/parsed.v1.json`; +} + +async function cleanupLegacyParsedPathCollision(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentKey(id, namespace); + const legacyPrefix = `${key}/`; + const legacyParsedKey = legacyDocumentParsedKey(id, namespace); + + const legacyObjects = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: legacyPrefix, + MaxKeys: 1, + }), + ); + const hasLegacyChildren = (legacyObjects.Contents?.length ?? 0) > 0; + if (!hasLegacyChildren) return; + + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); + await deleteDocumentPrefix(legacyPrefix).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })).catch(() => undefined); +} + export async function presignPut( id: string, contentType: string, namespace: string | null, ): Promise<{ url: string; headers: Record }> { + await cleanupLegacyParsedPathCollision(id, namespace); + const cfg = getS3Config(); const client = getS3Client(); const key = documentKey(id, namespace); @@ -94,7 +138,6 @@ export async function presignPut( Bucket: cfg.bucket, Key: key, ContentType: normalizedType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }); const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 }); @@ -103,7 +146,6 @@ export async function presignPut( url, headers: { 'Content-Type': normalizedType, - 'If-None-Match': '*', 'x-amz-server-side-encryption': 'AES256', }, }; @@ -169,6 +211,35 @@ export async function getDocumentBlobStream(id: string, namespace: string | null return res.Body as DocumentBlobBody; } +export async function getParsedDocumentBlob(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function putParsedDocumentBlob(id: string, body: Buffer, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: 'application/json', + ServerSideEncryption: 'AES256', + }), + ); + return key; +} + export async function presignGet( id: string, namespace: string | null, @@ -193,6 +264,8 @@ export async function putDocumentBlob( contentType: string, namespace: string | null, ): Promise { + await cleanupLegacyParsedPathCollision(id, namespace); + const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); @@ -202,7 +275,6 @@ export async function putDocumentBlob( Key: key, Body: body, ContentType: contentType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }), ); @@ -212,7 +284,13 @@ export async function deleteDocumentBlob(id: string, namespace: string | null): const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); + const parsedKey = documentParsedKey(id, namespace); + const legacyParsedKey = legacyDocumentParsedKey(id, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); + await deleteDocumentPrefix(`${key}/`).catch(() => undefined); } export function isMissingBlobError(error: unknown): boolean { diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts new file mode 100644 index 0000000..eab9f02 --- /dev/null +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -0,0 +1,92 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { UnsupportedComputeError } from '@/lib/server/compute/types'; +import { getDocumentBlob, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { getCompute } from '@/lib/server/compute'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; + +interface ParsePdfJobInput { + documentId: string; + userId: string; + namespace: string | null; +} + +const running = new Set(); + +function keyFor(input: ParsePdfJobInput): string { + return `${input.userId}:${input.documentId}:${input.namespace || ''}`; +} + +export async function parsePdfJob(input: ParsePdfJobInput): Promise { + const key = keyFor(input); + if (running.has(key)) return; + running.add(key); + + try { + await db + .update(documents) + .set({ parseStatus: 'running' }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + + const pdfBytes = await getDocumentBlob(input.documentId, input.namespace); + const parsed = await getCompute().parsePdfLayout({ + documentId: input.documentId, + pdfBytes: new Uint8Array(pdfBytes).buffer, + }); + + const parsedJson = Buffer.from(JSON.stringify(parsed)); + const parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); + + const cleared = await clearTtsSegmentCache({ + userId: input.userId, + documentId: input.documentId, + readerType: 'pdf', + }); + if (cleared.warning) { + console.warn('[parsePdfJob] cache invalidation warning', { + documentId: input.documentId, + warning: cleared.warning, + }); + } + + await db + .update(documents) + .set({ parseStatus: 'ready', parsedJsonKey }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + const cause = error instanceof Error ? error.cause : undefined; + const parseStatus = error instanceof UnsupportedComputeError ? 'unsupported' : 'failed'; + try { + await db + .update(documents) + .set({ parseStatus }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + } catch (statusError) { + console.error('[parsePdfJob] failed to write parse status', { + documentId: input.documentId, + parseStatus, + error: statusError instanceof Error ? statusError.message : String(statusError), + }); + } + console.error('[parsePdfJob] failed', { + documentId: input.documentId, + parseStatus, + error: message, + ...(stack ? { stack } : {}), + ...(cause ? { cause: String(cause) } : {}), + }); + } finally { + running.delete(key); + } +} + +export function enqueueParsePdfJob(input: ParsePdfJobInput): void { + Promise.resolve() + .then(() => parsePdfJob(input)) + .catch((error) => { + console.error('[parsePdfJob] uncaught error', error); + }); +} diff --git a/src/lib/server/pdf-layout/ensureModel.ts b/src/lib/server/pdf-layout/ensureModel.ts new file mode 100644 index 0000000..70d5c73 --- /dev/null +++ b/src/lib/server/pdf-layout/ensureModel.ts @@ -0,0 +1,99 @@ +import path from 'path'; +import { createHash } from 'crypto'; +import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; +import manifest from '@/lib/server/pdf-layout/model/manifest.json'; + +const DEFAULT_MODEL_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; +const DEFAULT_CONFIG_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); +const MODEL_PATH = path.join(MODEL_DIR, 'docling-layout-heron.onnx'); +const CONFIG_PATH = path.join(MODEL_DIR, 'docling-layout-heron.config.json'); +const LICENSE_PATH = path.join(MODEL_DIR, 'docling-layout-heron.LICENSE.txt'); +const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/pdf-layout/model/LICENSE.txt'); + +let inflight: Promise | null = null; + +async function sha256Hex(filePath: string): Promise { + const bytes = await readFile(filePath); + return createHash('sha256').update(bytes).digest('hex'); +} + +async function downloadToFile(url: string, outPath: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +function manifestEntry(filePath: string): { sha256: string; size: number } | null { + const found = manifest.files.find((entry) => entry.path === filePath); + if (!found || !found.sha256) return null; + return { + sha256: found.sha256.toLowerCase(), + size: Number(found.size), + }; +} + +async function verifyFile(pathToFile: string, manifestPath: string): Promise { + const expected = manifestEntry(manifestPath); + if (!expected) return true; + const bytes = await readFile(pathToFile); + if (Number.isFinite(expected.size) && expected.size > 0 && bytes.byteLength !== expected.size) { + return false; + } + const actual = await sha256Hex(pathToFile); + return actual === expected.sha256; +} + +async function ensureLicense(): Promise { + await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); + if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { + throw new Error('Docling model license checksum verification failed'); + } +} + +async function ensureModelInternal(): Promise { + try { + await access(MODEL_PATH); + await access(CONFIG_PATH); + if (await verifyFile(MODEL_PATH, 'model.onnx') && await verifyFile(CONFIG_PATH, 'config.json')) { + await ensureLicense(); + return MODEL_PATH; + } + } catch { + // continue + } + + await mkdir(MODEL_DIR, { recursive: true }); + const tmpPath = `${MODEL_PATH}.tmp`; + const configTmpPath = `${CONFIG_PATH}.tmp`; + const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL?.trim() || DEFAULT_MODEL_URL; + const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL?.trim() || DEFAULT_CONFIG_URL; + + await downloadToFile(modelUrl, tmpPath); + if (!(await verifyFile(tmpPath, 'model.onnx'))) { + await unlink(tmpPath).catch(() => undefined); + throw new Error('Docling model checksum verification failed'); + } + await downloadToFile(configUrl, configTmpPath); + if (!(await verifyFile(configTmpPath, 'config.json'))) { + await unlink(configTmpPath).catch(() => undefined); + throw new Error('Docling model config checksum verification failed'); + } + + await rename(tmpPath, MODEL_PATH); + await rename(configTmpPath, CONFIG_PATH); + await ensureLicense(); + return MODEL_PATH; +} + +export async function ensureModel(): Promise { + if (inflight) return inflight; + inflight = ensureModelInternal().finally(() => { + inflight = null; + }); + return inflight; +} diff --git a/src/lib/server/pdf-layout/mergeTextWithRegions.ts b/src/lib/server/pdf-layout/mergeTextWithRegions.ts new file mode 100644 index 0000000..8b56834 --- /dev/null +++ b/src/lib/server/pdf-layout/mergeTextWithRegions.ts @@ -0,0 +1,145 @@ +import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; + +const NON_TEXT_REGION_LABELS = new Set(['picture', 'table']); +const TEXT_ASSIGNABLE_LABELS = new Set([ + 'title', + 'section-header', + 'paragraph', + 'list-item', + 'caption', + 'page-header', + 'page-footer', + 'footnote', + 'formula', +]); + +function centroid(item: PdfTextItem): { x: number; y: number } { + return { + x: item.x + item.width / 2, + y: item.y + item.height / 2, + }; +} + +function contains(region: LayoutRegion, item: PdfTextItem): boolean { + const c = centroid(item); + return c.x >= region.bbox[0] && c.x <= region.bbox[2] && c.y >= region.bbox[1] && c.y <= region.bbox[3]; +} + +function sortReadingOrder(items: PdfTextItem[]): PdfTextItem[] { + const tolerance = 2; + return [...items].sort((a, b) => { + if (Math.abs(a.y - b.y) <= tolerance) return a.x - b.x; + return a.y - b.y; + }); +} + +function joinText(items: PdfTextItem[]): string { + let out = ''; + let prev: PdfTextItem | null = null; + for (const item of items) { + if (!prev) { + out += item.text; + prev = item; + continue; + } + const prevEndX = prev.x + prev.width; + const gap = item.x - prevEndX; + const lineJump = item.y - prev.y; + const lineBreak = lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6); + const avgCharWidth = item.width / Math.max(1, item.text.length); + const needsSpace = lineBreak || gap > Math.max(avgCharWidth * 0.3, 2); + out += needsSpace ? ` ${item.text}` : item.text; + prev = item; + } + return out.replace(/\s+/g, ' ').trim(); +} + +function regionArea(region: LayoutRegion): number { + return Math.max(1, (region.bbox[2] - region.bbox[0]) * (region.bbox[3] - region.bbox[1])); +} + +function regionScore(region: LayoutRegion): number { + return Number.isFinite(region.confidence) ? Number(region.confidence) : 0; +} + +export interface RegionTextBlock { + region: LayoutRegion; + text: string; + items: PdfTextItem[]; + sourceOrder: number; +} + +export function mergeTextWithRegions(regions: LayoutRegion[], textItems: PdfTextItem[]): RegionTextBlock[] { + const sourceIndex = new Map(); + for (let i = 0; i < textItems.length; i += 1) { + sourceIndex.set(textItems[i]!, i); + } + + const chunkSourceOrder = (items: PdfTextItem[]): number => { + let min = Number.POSITIVE_INFINITY; + for (const item of items) { + const index = sourceIndex.get(item); + if (typeof index === 'number' && index < min) min = index; + } + return Number.isFinite(min) ? min : Number.MAX_SAFE_INTEGER; + }; + + const assignableRegions = regions + .map((region, index) => ({ region, index })) + .filter(({ region }) => TEXT_ASSIGNABLE_LABELS.has(region.label)); + const assignedByRegion = new Map(); + + for (const item of textItems) { + const candidates = assignableRegions.filter(({ region }) => contains(region, item)); + if (candidates.length === 0) continue; + + candidates.sort((a, b) => { + const scoreDelta = regionScore(b.region) - regionScore(a.region); + if (Math.abs(scoreDelta) > 1e-6) return scoreDelta; + return regionArea(a.region) - regionArea(b.region); + }); + + const winner = candidates[0]; + const list = assignedByRegion.get(winner.index) ?? []; + list.push(item); + assignedByRegion.set(winner.index, list); + } + + const out: RegionTextBlock[] = []; + + for (const [regionIndex, assignedItems] of assignedByRegion.entries()) { + const region = regions[regionIndex]; + if (!region) continue; + if (assignedItems.length === 0) continue; + const ordered = sortReadingOrder(assignedItems); + const text = joinText(ordered); + if (!text) continue; + + out.push({ + region, + text, + items: ordered, + sourceOrder: chunkSourceOrder(ordered), + }); + } + + for (const region of regions) { + if (!NON_TEXT_REGION_LABELS.has(region.label)) continue; + out.push({ + region, + text: '', + items: [], + sourceOrder: Number.MAX_SAFE_INTEGER, + }); + } + + out.sort((a, b) => { + if (a.sourceOrder !== b.sourceOrder) return a.sourceOrder - b.sourceOrder; + const ay = a.region.bbox[1]; + const by = b.region.bbox[1]; + if (Math.abs(ay - by) <= 2) return a.region.bbox[0] - b.region.bbox[0]; + return ay - by; + }); + + return out; +} diff --git a/src/lib/server/pdf-layout/model/LICENSE.txt b/src/lib/server/pdf-layout/model/LICENSE.txt new file mode 100644 index 0000000..d2b2034 --- /dev/null +++ b/src/lib/server/pdf-layout/model/LICENSE.txt @@ -0,0 +1,3 @@ +Docling layout model assets are distributed under Apache-2.0 / CDLA terms by the model publisher. +See the upstream release for the authoritative license text: +https://huggingface.co/ds4sd/docling-layout-heron diff --git a/src/lib/server/pdf-layout/model/manifest.json b/src/lib/server/pdf-layout/model/manifest.json new file mode 100644 index 0000000..c688e28 --- /dev/null +++ b/src/lib/server/pdf-layout/model/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "docling-layout-heron", + "version": "docling-project/docling-layout-heron-onnx@main", + "files": [ + { + "path": "model.onnx", + "sha256": "59c81a3a2923042d85034ffc487f8f47e4854117e879aef89b2b9f728fb4922a", + "size": 171220471 + }, + { + "path": "config.json", + "sha256": "c6e67b6cdf64fa245779d0409bb994aa96e8c1790e15ec07a65843628e232180", + "size": 878 + }, + { + "path": "LICENSE.txt", + "sha256": "8f5030e085c59609746fce1010230f12b4a565abb6bdc19a2c2d3735ba1710d9", + "size": 209 + } + ] +} diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts new file mode 100644 index 0000000..e95cc35 --- /dev/null +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -0,0 +1,153 @@ +import path from 'path'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf'; +import type { PdfTextItem } from '@/lib/server/pdf-layout/types'; +import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; +import { runLayoutModel } from '@/lib/server/pdf-layout/runLayoutModel'; +import { mergeTextWithRegions } from '@/lib/server/pdf-layout/mergeTextWithRegions'; +import { stitchCrossPageBlocks } from '@/lib/server/pdf-layout/stitchCrossPageBlocks'; +import { renderPage } from '@/lib/server/pdf-layout/renderPage'; + +interface ParsePdfInput { + documentId: string; + pdfBytes: ArrayBuffer; +} + +const LAYOUT_RENDER_SCALE = 1.5; + +function normalizeTextItems(items: TextItem[], pageHeight: number): PdfTextItem[] { + return items + .filter((item) => typeof item.str === 'string' && item.str.trim().length > 0) + .map((item) => { + const x = Number(item.transform[4] ?? 0); + const width = Math.max(0, Number(item.width ?? 0)); + const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1))); + const baselineY = Number(item.transform[5] ?? 0); + // pdf.js text transforms are in PDF user-space (origin bottom-left). + // Normalize into top-left page coordinates to match rendered image/model boxes. + const y = Math.max(0, pageHeight - baselineY - height); + return { + text: item.str, + x, + y, + width, + height, + }; + }); +} + +export async function parsePdf(input: ParsePdfInput): Promise { + await ensureModel(); + + // Keep independent byte copies for text extraction and page rendering. pdf.js + // can detach buffers passed to getDocument(). + const pdfBytesForText = new Uint8Array(input.pdfBytes).slice(); + const pdfBytesForRender = new Uint8Array(input.pdfBytes).slice(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: pdfBytesForText, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + }); + const pdf = await loadingTask.promise; + + try { + const pages: ParsedPdfPage[] = []; + let nextBlockId = 1; + let sawText = false; + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1.0 }); + const textContent = await page.getTextContent(); + const textItems = normalizeTextItems( + textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), + viewport.height, + ); + + if (textItems.length > 0) sawText = true; + + const rendered = await renderPage({ + pdfBytes: pdfBytesForRender.buffer.slice( + pdfBytesForRender.byteOffset, + pdfBytesForRender.byteOffset + pdfBytesForRender.byteLength, + ), + pageNumber, + scale: LAYOUT_RENDER_SCALE, + }); + const scaleX = rendered.width / Math.max(1, viewport.width); + const scaleY = rendered.height / Math.max(1, viewport.height); + const layoutTextItems = textItems.map((item) => ({ + ...item, + x: item.x * scaleX, + y: item.y * scaleY, + width: item.width * scaleX, + height: item.height * scaleY, + })); + const regions = await runLayoutModel({ + pageWidth: rendered.width, + pageHeight: rendered.height, + textItems: layoutTextItems, + pagePng: rendered.png, + }); + const merged = mergeTextWithRegions(regions, layoutTextItems); + if (textItems.length > 0 && merged.length === 0) { + throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`); + } + + const blocks = merged + .map((entry, readingOrder) => ({ + id: `b${String(nextBlockId++).padStart(4, '0')}`, + kind: entry.region.label, + fragments: [{ + page: pageNumber, + bbox: [ + entry.region.bbox[0] / scaleX, + entry.region.bbox[1] / scaleY, + entry.region.bbox[2] / scaleX, + entry.region.bbox[3] / scaleY, + ] as [number, number, number, number], + text: entry.text, + readingOrder, + ...(typeof entry.region.confidence === 'number' ? { modelConfidence: entry.region.confidence } : {}), + }], + text: entry.text, + })); + + pages.push({ + pageNumber, + width: viewport.width, + height: viewport.height, + blocks, + }); + } + + if (!sawText) { + throw new Error('no-text-layer'); + } + + const doc: ParsedPdfDocument = { + schemaVersion: 1, + documentId: input.documentId, + parserVersion: 'docling-layout-heron@v1+pdfjs@4.8.69', + parsedAt: Date.now(), + pages, + }; + + return stitchCrossPageBlocks(doc); + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/src/lib/server/pdf-layout/renderPage.ts b/src/lib/server/pdf-layout/renderPage.ts new file mode 100644 index 0000000..0556176 --- /dev/null +++ b/src/lib/server/pdf-layout/renderPage.ts @@ -0,0 +1,142 @@ +import path from 'path'; +import type { Canvas } from '@napi-rs/canvas'; + +type CanvasRuntime = { + DOMMatrixCtor: unknown; + Path2DCtor: unknown; + createCanvasFn: (width: number, height: number) => Canvas; +}; + +let canvasRuntimePromise: Promise | null = null; + +async function loadCanvasRuntime(): Promise { + if (!canvasRuntimePromise) { + canvasRuntimePromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => Canvas) + | undefined; + const DOMMatrixCtor = namespace.DOMMatrix ?? fallback.DOMMatrix; + const Path2DCtor = namespace.Path2D ?? fallback.Path2D; + + if (typeof createCanvasFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas export (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + if (!DOMMatrixCtor || !Path2DCtor) { + throw new Error( + `Canvas runtime missing DOMMatrix/Path2D exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { + DOMMatrixCtor, + Path2DCtor, + createCanvasFn, + }; + })(); + } + return canvasRuntimePromise; +} + +function ensureNodeCanvasGlobals(runtime: CanvasRuntime): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = runtime.DOMMatrixCtor; + if (typeof g.Path2D === 'undefined') g.Path2D = runtime.Path2DCtor; +} + +interface RenderInput { + pdfBytes: ArrayBuffer; + pageNumber: number; + scale?: number; +} + +function createPdfjsCanvasFactory(runtime: CanvasRuntime) { + return class OpenReaderCanvasFactory { + create(width: number, height: number) { + const canvas = runtime.createCanvasFn(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height))); + return { + canvas, + context: canvas.getContext('2d') as unknown as CanvasRenderingContext2D, + }; + } + + reset(target: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number): void { + target.canvas.width = Math.max(1, Math.floor(width)); + target.canvas.height = Math.max(1, Math.floor(height)); + } + + destroy(target: { canvas: Canvas; context: CanvasRenderingContext2D }): void { + target.canvas.width = 0; + target.canvas.height = 0; + // @ts-expect-error pdf.js expects these nulled on destroy + target.canvas = null; + // @ts-expect-error pdf.js expects these nulled on destroy + target.context = null; + } + }; +} + +export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderInput): Promise<{ + width: number; + height: number; + png: Buffer; +}> { + // pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so + // callers can safely reuse their original bytes across pages/calls. + const isolatedBytes = new Uint8Array(pdfBytes).slice(); + + const canvasRuntime = await loadCanvasRuntime(); + ensureNodeCanvasGlobals(canvasRuntime); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: isolatedBytes, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + // Ensure pdf.js transport uses our canvas backend in Node/Next runtime. + CanvasFactory: createPdfjsCanvasFactory(canvasRuntime), + }); + + const pdf = await loadingTask.promise; + try { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale }); + const width = Math.max(1, Math.floor(viewport.width)); + const height = Math.max(1, Math.floor(viewport.height)); + const canvas = canvasRuntime.createCanvasFn(width, height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + + const renderTask = page.render({ + canvasContext: ctx as unknown as CanvasRenderingContext2D, + viewport, + intent: 'display', + }); + await renderTask.promise; + return { + width, + height, + png: canvas.toBuffer('image/png'), + }; + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/src/lib/server/pdf-layout/runLayoutModel.ts b/src/lib/server/pdf-layout/runLayoutModel.ts new file mode 100644 index 0000000..608de35 --- /dev/null +++ b/src/lib/server/pdf-layout/runLayoutModel.ts @@ -0,0 +1,241 @@ +import * as ort from 'onnxruntime-node'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; +import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; + +interface RunLayoutInput { + pageWidth: number; + pageHeight: number; + textItems: PdfTextItem[]; + pagePng: Buffer; +} + +const INPUT_SIZE = 640; +const MIN_SCORE = 0.6; + +const LABEL_MAP: Record = { + caption: 'caption', + footnote: 'footnote', + formula: 'formula', + list_item: 'list-item', + page_footer: 'page-footer', + page_header: 'page-header', + picture: 'picture', + section_header: 'section-header', + table: 'table', + text: 'paragraph', + title: 'title', + document_index: null, + code: null, + checkbox_selected: null, + checkbox_unselected: null, + form: null, + key_value_region: null, +}; + +const MIN_REGION_SIZE: Partial> = { + paragraph: { minWidth: 24, minHeight: 14 }, + 'section-header': { minWidth: 24, minHeight: 14 }, + title: { minWidth: 24, minHeight: 14 }, + 'list-item': { minWidth: 18, minHeight: 12 }, + caption: { minWidth: 18, minHeight: 10 }, + footnote: { minWidth: 18, minHeight: 10 }, + 'page-header': { minWidth: 18, minHeight: 10 }, + 'page-footer': { minWidth: 18, minHeight: 10 }, +}; + +let sessionPromise: Promise | null = null; +let idToLabelPromise: Promise> | null = null; +let canvasFnsPromise: Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> | null = null; + +async function getCanvasFns(): Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> { + if (!canvasFnsPromise) { + canvasFnsPromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }) + | undefined; + const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as + | ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>) + | undefined; + + if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { createCanvasFn, loadImageFn }; + })(); + } + return canvasFnsPromise; +} + +async function getSession(): Promise { + if (!sessionPromise) { + sessionPromise = (async () => { + const modelPath = await ensureModel(); + return ort.InferenceSession.create(modelPath, { + executionProviders: ['cpu'], + graphOptimizationLevel: 'all', + }); + })(); + } + return sessionPromise; +} + +async function getIdToLabel(): Promise> { + if (!idToLabelPromise) { + idToLabelPromise = (async () => { + const modelPath = await ensureModel(); + const configPath = path.join(path.dirname(modelPath), 'docling-layout-heron.config.json'); + const raw = await readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { id2label?: Record }; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed.id2label ?? {})) { + const n = Number(key); + if (Number.isFinite(n)) out[n] = value; + } + return out; + })(); + } + return idToLabelPromise; +} + +function preprocessLetterboxed( + image: CanvasImageSource, + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, +): { + tensor: ort.Tensor; + scale: number; + padX: number; + padY: number; +} { + const sourceWidth = Math.max(1, Number((image as { width?: number }).width ?? INPUT_SIZE)); + const sourceHeight = Math.max(1, Number((image as { height?: number }).height ?? INPUT_SIZE)); + const scale = Math.min(INPUT_SIZE / sourceWidth, INPUT_SIZE / sourceHeight); + const drawWidth = Math.max(1, Math.round(sourceWidth * scale)); + const drawHeight = Math.max(1, Math.round(sourceHeight * scale)); + const padX = Math.floor((INPUT_SIZE - drawWidth) / 2); + const padY = Math.floor((INPUT_SIZE - drawHeight) / 2); + + const canvas = createCanvasFn(INPUT_SIZE, INPUT_SIZE); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, padX, padY, drawWidth, drawHeight); + + const imageData = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); + const data = new Uint8Array(1 * 3 * INPUT_SIZE * INPUT_SIZE); + for (let y = 0; y < INPUT_SIZE; y += 1) { + for (let x = 0; x < INPUT_SIZE; x += 1) { + const pixelIndex = (y * INPUT_SIZE + x) * 4; + const chwIndex = y * INPUT_SIZE + x; + data[0 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex]; + data[1 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 1]; + data[2 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 2]; + } + } + + return { + tensor: new ort.Tensor('uint8', data, [1, 3, INPUT_SIZE, INPUT_SIZE]), + scale, + padX, + padY, + }; +} + +function clampBox( + bbox: [number, number, number, number], + pageWidth: number, + pageHeight: number, +): [number, number, number, number] | null { + const x0 = Math.max(0, Math.min(pageWidth, bbox[0])); + const y0 = Math.max(0, Math.min(pageHeight, bbox[1])); + const x1 = Math.max(0, Math.min(pageWidth, bbox[2])); + const y1 = Math.max(0, Math.min(pageHeight, bbox[3])); + if (x1 <= x0 || y1 <= y0) return null; + return [x0, y0, x1, y1]; +} + +export async function runLayoutModel(input: RunLayoutInput): Promise { + const { pageWidth, pageHeight, textItems, pagePng } = input; + if (!textItems.length) return []; + if (!pagePng || pagePng.byteLength === 0) { + throw new Error('layout-render-missing-page-image'); + } + + try { + const [session, idToLabel, canvasFns] = await Promise.all([getSession(), getIdToLabel(), getCanvasFns()]); + const pageImage = await canvasFns.loadImageFn(pagePng); + const preprocess = preprocessLetterboxed(pageImage, canvasFns.createCanvasFn); + const targetSizes = new ort.Tensor('int64', new BigInt64Array([BigInt(INPUT_SIZE), BigInt(INPUT_SIZE)]), [1, 2]); + const output = await session.run({ + images: preprocess.tensor, + orig_target_sizes: targetSizes, + }); + + const labels = output.labels?.data as BigInt64Array | Int32Array | undefined; + const boxes = output.boxes?.data as Float32Array | undefined; + const scores = output.scores?.data as Float32Array | undefined; + if (!labels || !boxes || !scores) return []; + + const regions: LayoutRegion[] = []; + const count = Math.min(scores.length, Math.floor(boxes.length / 4), labels.length); + for (let i = 0; i < count; i += 1) { + const score = scores[i]; + if (!Number.isFinite(score) || score < MIN_SCORE) continue; + + const rawLabel = Number(labels[i]); + const labelName = idToLabel[rawLabel]; + if (!labelName) continue; + const mapped = LABEL_MAP[labelName]; + if (!mapped) continue; + + const modelBox: [number, number, number, number] = [ + boxes[i * 4 + 0], + boxes[i * 4 + 1], + boxes[i * 4 + 2], + boxes[i * 4 + 3], + ]; + const rawBox: [number, number, number, number] = [ + (modelBox[0] - preprocess.padX) / preprocess.scale, + (modelBox[1] - preprocess.padY) / preprocess.scale, + (modelBox[2] - preprocess.padX) / preprocess.scale, + (modelBox[3] - preprocess.padY) / preprocess.scale, + ]; + const clamped = clampBox(rawBox, pageWidth, pageHeight); + if (!clamped) continue; + + const sizeRule = MIN_REGION_SIZE[mapped]; + if (sizeRule) { + const width = clamped[2] - clamped[0]; + const height = clamped[3] - clamped[1]; + if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue; + } + + regions.push({ + bbox: clamped, + label: mapped, + confidence: score, + }); + } + + return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + } catch (error) { + throw new Error( + `layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts new file mode 100644 index 0000000..efd8a54 --- /dev/null +++ b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts @@ -0,0 +1,80 @@ +import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; + +function stripTrailingClosers(text: string): string { + return text.trim().replace(/[\"'”’\]\)]+$/g, ''); +} + +function isSentenceTerminal(text: string): boolean { + return /[.!?]$/.test(stripTrailingClosers(text)); +} + +function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { + if (!['paragraph', 'list-item'].includes(a.kind)) return false; + if (a.kind !== b.kind) return false; + if (isSentenceTerminal(a.text)) return false; + const next = b.text.trim(); + if (!next) return false; + if (/^[A-Z]/.test(next)) return false; + return true; +} + +const HARD_BOUNDARY_KINDS = new Set(['section-header', 'title']); + +function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + } + return -1; +} + +function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + } + return -1; +} + +function hasHardBoundaryBetween( + pageBlocks: ParsedPdfBlock[], + startInclusive: number, + endExclusive: number, +): boolean { + for (let i = startInclusive; i < endExclusive; i += 1) { + const block = pageBlocks[i]; + if (block && HARD_BOUNDARY_KINDS.has(block.kind)) return true; + } + return false; +} + +export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument { + const pages = doc.pages.map((page) => ({ ...page, blocks: page.blocks.map((b) => ({ ...b, fragments: b.fragments.map((f) => ({ ...f })) })) })); + + for (let i = 0; i < pages.length - 1; i += 1) { + const page = pages[i]; + const next = pages[i + 1]; + const tailIndex = findTailCandidateIndex(page.blocks); + const headIndex = findHeadCandidateIndex(next.blocks); + if (tailIndex < 0 || headIndex < 0) continue; + + if (hasHardBoundaryBetween(page.blocks, tailIndex + 1, page.blocks.length)) continue; + if (hasHardBoundaryBetween(next.blocks, 0, headIndex)) continue; + + const tail = page.blocks[tailIndex]; + const head = next.blocks[headIndex]; + if (!tail || !head) continue; + if (!canStitch(tail, head)) continue; + + tail.fragments.push(...head.fragments); + tail.text = `${tail.text} ${head.text}`.replace(/\s+/g, ' ').trim(); + next.blocks.splice(headIndex, 1); + } + + return { + ...doc, + pages, + }; +} diff --git a/src/lib/server/pdf-layout/types.ts b/src/lib/server/pdf-layout/types.ts new file mode 100644 index 0000000..c2e1079 --- /dev/null +++ b/src/lib/server/pdf-layout/types.ts @@ -0,0 +1,15 @@ +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +export interface PdfTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LayoutRegion { + bbox: [number, number, number, number]; + label: ParsedPdfBlockKind; + confidence?: number; +} diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index b31eee6..87889d8 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,8 +6,11 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; +import { isComputeAvailable } from '@/lib/server/compute'; -export type ResolvedRuntimeConfig = RuntimeConfig; +export type ResolvedRuntimeConfig = RuntimeConfig & { + computeAvailable: boolean; +}; function assertServerRuntime(caller: string): void { if (typeof window !== 'undefined') { @@ -23,7 +26,11 @@ function assertServerRuntime(caller: string): void { export async function getResolvedRuntimeConfig(): Promise { assertServerRuntime('getResolvedRuntimeConfig'); await ensureAdminSeed(); - return getRuntimeConfig(); + const values = await getRuntimeConfig(); + return { + ...values, + computeAvailable: isComputeAvailable(), + }; } export async function getResolvedRuntimeConfigWithSources(): Promise<{ diff --git a/src/lib/server/storage/s3.ts b/src/lib/server/storage/s3.ts index df482ab..70b63d3 100644 --- a/src/lib/server/storage/s3.ts +++ b/src/lib/server/storage/s3.ts @@ -90,6 +90,8 @@ export function getS3Client(): S3Client { region: config.region, endpoint: config.endpoint, forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -111,6 +113,8 @@ export function getS3ProxyClient(): S3Client { region: config.region, endpoint: loopbackEndpoint(config.endpoint), forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, diff --git a/src/lib/server/tts/segments-cache.ts b/src/lib/server/tts/segments-cache.ts new file mode 100644 index 0000000..d893f5c --- /dev/null +++ b/src/lib/server/tts/segments-cache.ts @@ -0,0 +1,82 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; +import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; +import type { ReaderType } from '@/types/user-state'; + +type ClearTtsSegmentCacheInput = { + userId: string; + documentId: string; + documentVersion?: number; + readerType?: ReaderType; +}; + +export type ClearTtsSegmentCacheResult = { + deletedSegments: number; + requestedAudioObjects: number; + deletedAudioObjects: number; + warning?: string; +}; + +export async function clearTtsSegmentCache( + input: ClearTtsSegmentCacheInput, +): Promise { + const conditions = [ + eq(ttsSegmentEntries.userId, input.userId), + eq(ttsSegmentEntries.documentId, input.documentId), + ]; + + if (typeof input.documentVersion === 'number' && Number.isFinite(input.documentVersion)) { + conditions.push(eq(ttsSegmentEntries.documentVersion, Math.floor(input.documentVersion))); + } + if (input.readerType) { + conditions.push(eq(ttsSegmentEntries.readerType, input.readerType)); + } + + const rows = (await db + .select({ + segmentId: ttsSegmentVariants.segmentId, + audioKey: ttsSegmentVariants.audioKey, + }) + .from(ttsSegmentVariants) + .innerJoin( + ttsSegmentEntries, + and( + eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), + eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), + ), + ) + .where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>; + + await db.delete(ttsSegmentEntries).where(and(...conditions)); + + const audioKeys = rows + .map((row) => row.audioKey) + .filter((key): key is string => Boolean(key)); + const uniqueAudioKeys = Array.from(new Set(audioKeys)); + + let deletedAudioObjects = 0; + let warning: string | undefined; + if (uniqueAudioKeys.length > 0) { + try { + deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); + if (deletedAudioObjects < uniqueAudioKeys.length) { + warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; + } + } catch (error) { + warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; + console.warn('Failed clearing some TTS segment audio objects:', { + documentId: input.documentId, + userId: input.userId, + error: warning, + }); + } + } + + return { + deletedSegments: rows.length, + requestedAudioObjects: uniqueAudioKeys.length, + deletedAudioObjects, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 9da16d2..d1bb3c7 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -81,6 +81,9 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg return { readerType: 'pdf', page: Math.max(1, Math.floor(locator.page)), + ...(typeof locator.blockId === 'string' && locator.blockId.trim() + ? { blockId: locator.blockId.trim() } + : {}), }; } if (locator.readerType === 'html') { diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts new file mode 100644 index 0000000..d22ef6c --- /dev/null +++ b/src/lib/shared/document-settings.ts @@ -0,0 +1,76 @@ +import { + DEFAULT_DOCUMENT_SETTINGS, + type DocumentSettings, +} from '@/types/document-settings'; +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +const PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [ + 'title', + 'section-header', + 'paragraph', + 'list-item', + 'caption', + 'table', + 'picture', + 'page-header', + 'page-footer', + 'footnote', + 'formula', +]; + +function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { + if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])]; + const allow = new Set(PDF_BLOCK_KINDS); + const out: ParsedPdfBlockKind[] = []; + for (const entry of value) { + if (typeof entry !== 'string') continue; + if (!allow.has(entry as ParsedPdfBlockKind)) continue; + out.push(entry as ParsedPdfBlockKind); + } + return Array.from(new Set(out)); +} + +type PdfMargins = { header: number; footer: number; left: number; right: number }; + +function normalizeMargins(value: unknown): PdfMargins | undefined { + if (!value || typeof value !== 'object') return undefined; + const rec = value as Record; + const header = Number(rec.header); + const footer = Number(rec.footer); + const left = Number(rec.left); + const right = Number(rec.right); + if (![header, footer, left, right].every((n) => Number.isFinite(n))) return undefined; + return { header, footer, left, right }; +} + +export function mergeDocumentSettings( + defaults: DocumentSettings = DEFAULT_DOCUMENT_SETTINGS, + stored: unknown, +): DocumentSettings { + const base: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], + chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true, + ...(defaults.pdf?.margins ? { margins: defaults.pdf.margins } : {}), + }, + }; + + if (!stored || typeof stored !== 'object') return base; + const rec = stored as Record; + const pdf = rec.pdf; + if (!pdf || typeof pdf !== 'object') return base; + const pdfRec = pdf as Record; + + return { + schemaVersion: 1, + pdf: { + skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds), + chaptersFromSections: + typeof pdfRec.chaptersFromSections === 'boolean' + ? pdfRec.chaptersFromSections + : (base.pdf?.chaptersFromSections ?? true), + ...(normalizeMargins(pdfRec.margins) ? { margins: normalizeMargins(pdfRec.margins) } : {}), + }, + }; +} diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index 54db024..4c7eb15 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -70,7 +70,10 @@ export function locatorIdentityKey(locator: TTSSegmentLocator | null): string { return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; } if (isPdfLocator(locator)) { - return `pdf:${Math.floor(locator.page)}`; + const blockPart = typeof locator.blockId === 'string' && locator.blockId.trim() + ? `:${locator.blockId.trim()}` + : ''; + return `pdf:${Math.floor(locator.page)}${blockPart}`; } if (isHtmlLocator(locator)) { return `html:${locator.location}`; @@ -112,7 +115,11 @@ export function compareSegmentLocators( return a.spineHref.localeCompare(b.spineHref); } if (isPdfLocator(a) && isPdfLocator(b)) { - return Math.floor(a.page) - Math.floor(b.page); + const pageCmp = Math.floor(a.page) - Math.floor(b.page); + if (pageCmp !== 0) return pageCmp; + const aBlock = typeof a.blockId === 'string' ? a.blockId : ''; + const bBlock = typeof b.blockId === 'string' ? b.blockId : ''; + return aBlock.localeCompare(bBlock); } if (isHtmlLocator(a) && isHtmlLocator(b)) { // When both locations look like positive integers (HTML reader blocks), diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index e2aa1ba..de3fd0c 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -37,6 +37,7 @@ export interface CanonicalTtsSegmentPlanOptions { readerType?: ReaderType; maxBlockLength?: number; keyPrefix?: string; + enforceSourceBoundaries?: boolean; } interface PreparedSourceUnit { @@ -185,6 +186,7 @@ export function planCanonicalTtsSegments( options: CanonicalTtsSegmentPlanOptions = {}, ): CanonicalTtsSegmentPlan { const readerType = options.readerType ?? 'pdf'; + const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries); const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; const preparedSources: PreparedSourceUnit[] = []; const textParts: string[] = []; @@ -263,6 +265,41 @@ export function planCanonicalTtsSegments( spansSourceBoundary: false, }); } else { + const splitAcrossSourceBoundaries = () => { + const ownerIdx = preparedSources.indexOf(ownerSource); + const endIdx = preparedSources.indexOf(endSource); + if (ownerIdx < 0 || endIdx < 0) return; + + let subStart = startOffset; + for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { + const source = preparedSources[srcIdx]; + const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; + if (subEnd <= subStart) continue; + + const subText = canonicalText.slice(subStart, subEnd).trim(); + if (!subText) { + subStart = subEnd; + continue; + } + + const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; + const subStartAnchor = anchorForOffset(source, subStart); + const subEndAnchor = anchorForOffset(source, subEnd); + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, subText), + ordinal, + text: subText, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: subStartAnchor, + endAnchor: subEndAnchor, + spansSourceBoundary: false, + }); + subStart = nextSource ? nextSource.startOffset : subEnd; + } + }; + // Block spans one or more source boundaries. Decide whether to keep it // as a single boundary-spanning segment or to split it at each boundary. // @@ -286,6 +323,12 @@ export function planCanonicalTtsSegments( // continuation, so it should be filtered out of the current page's // segments. if (ownerSource.locator !== null) { + if (enforceSourceBoundaries) { + // PDF source units are logical layout blocks; never allow a segment + // to cross a block boundary. + splitAcrossSourceBoundaries(); + continue; + } // Both sources carry locators → original unified boundary behavior. const ordinal = segments.length; const startAnchor = anchorForOffset(ownerSource, startOffset); @@ -318,34 +361,7 @@ export function planCanonicalTtsSegments( if (isCleanBoundary) { // Clean boundary → split at each source boundary. - let subStart = startOffset; - for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { - const source = preparedSources[srcIdx]; - const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; - if (subEnd <= subStart) continue; - - const subText = canonicalText.slice(subStart, subEnd).trim(); - if (!subText) { - subStart = subEnd; - continue; - } - - const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; - const subStartAnchor = anchorForOffset(source, subStart); - const subEndAnchor = anchorForOffset(source, subEnd); - const ordinal = segments.length; - segments.push({ - key: buildSegmentKey(keyPrefix, subText), - ordinal, - text: subText, - ownerSourceKey: source.sourceKey, - ownerLocator: source.locator, - startAnchor: subStartAnchor, - endAnchor: subEndAnchor, - spansSourceBoundary: false, - }); - subStart = nextSource ? nextSource.startOffset : subEnd; - } + splitAcrossSourceBoundaries(); } else { // Overlapping sentence → keep unified, owned by the context-only // source. This segment will be filtered out of the current page's diff --git a/src/types/client.ts b/src/types/client.ts index 5ce3e82..aed3042 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -107,6 +107,8 @@ export interface TTSSegmentLocator { readerType?: TTSReaderType; // PDF / legacy page?: number; + // PDF block-level locator (structured parser path) + blockId?: string; // HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB) location?: string; // Stable EPUB coordinates diff --git a/src/types/config.ts b/src/types/config.ts index 7601b06..03881c3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -8,15 +8,6 @@ import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; // `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and // from the built-in defaults during SSR. -function readRuntimeFlag(key: string, defaultValue: boolean): boolean { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'boolean' ? value : defaultValue; -} - function readRuntimeString(key: string, defaultValue: string): string { if (typeof window === 'undefined') return defaultValue; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -96,7 +87,6 @@ export interface AppConfigValues { * that reads through it. */ export function getAppConfigDefaults(): AppConfigValues { - const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true); const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); const defaultProviderRef = runtimeProviderRef.trim(); const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; @@ -126,11 +116,11 @@ export function getAppConfigDefaults(): AppConfigValues { segmentPreloadSentenceLookahead: 3, ttsSegmentMaxBlockLength: 450, pdfHighlightEnabled: true, - pdfWordHighlightEnabled: wordHighlightEnabledByDefault, + pdfWordHighlightEnabled: true, epubHighlightEnabled: true, - epubWordHighlightEnabled: wordHighlightEnabledByDefault, + epubWordHighlightEnabled: true, htmlHighlightEnabled: true, - htmlWordHighlightEnabled: wordHighlightEnabledByDefault, + htmlWordHighlightEnabled: true, firstVisit: false, documentListState: { sortBy: 'name', diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts new file mode 100644 index 0000000..bb591ad --- /dev/null +++ b/src/types/document-settings.ts @@ -0,0 +1,18 @@ +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +export interface DocumentSettings { + schemaVersion: 1; + pdf?: { + skipBlockKinds: ParsedPdfBlockKind[]; + margins?: { header: number; footer: number; left: number; right: number }; + chaptersFromSections: boolean; + }; +} + +export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['page-header', 'page-footer', 'footnote'], + chaptersFromSections: true, + }, +}; diff --git a/src/types/documents.ts b/src/types/documents.ts index cae8b99..b25d8fe 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,6 +6,8 @@ export interface BaseDocument { size: number; lastModified: number; type: DocumentType; + parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parsedJsonKey?: string | null; scope?: 'user' | 'unclaimed'; folderId?: string; isConverting?: boolean; diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts new file mode 100644 index 0000000..f64f126 --- /dev/null +++ b/src/types/parsed-pdf.ts @@ -0,0 +1,45 @@ +export type ParsedPdfBlockKind = + | 'title' + | 'section-header' + | 'paragraph' + | 'list-item' + | 'caption' + | 'table' + | 'picture' + | 'page-header' + | 'page-footer' + | 'footnote' + | 'formula'; + +export interface ParsedPdfBlockFragment { + page: number; + bbox: [number, number, number, number]; + text: string; + readingOrder: number; + modelConfidence?: number; +} + +export interface ParsedPdfBlock { + id: string; + kind: ParsedPdfBlockKind; + fragments: ParsedPdfBlockFragment[]; + text: string; + parentSectionId?: string; +} + +export interface ParsedPdfPage { + pageNumber: number; + width: number; + height: number; + blocks: ParsedPdfBlock[]; +} + +export interface ParsedPdfDocument { + schemaVersion: 1; + documentId: string; + parserVersion: string; + parsedAt: number; + pages: ParsedPdfPage[]; +} + +export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed' | 'unsupported'; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts new file mode 100644 index 0000000..d430401 --- /dev/null +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { createPdfAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/pdf'; +import type { ParsedPdfDocument } from '../../src/types/parsed-pdf'; +import type { DocumentSettings } from '../../src/types/document-settings'; + +test.describe('pdf audiobook adapter', () => { + test('builds chapters from section headers and filters skipped kinds', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-1', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b1', + kind: 'section-header', + text: 'Intro', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }], + }, + { + id: 'b2', + kind: 'paragraph', + text: 'Welcome text.', + fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'page-header', + text: 'Header line', + fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }], + }, + { + id: 'b4', + kind: 'section-header', + text: 'Second', + fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }], + }, + { + id: 'b5', + kind: 'paragraph', + text: 'More body.', + fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['page-header'], + chaptersFromSections: true, + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + margins: { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, + smartSentenceSplitting: false, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0].title).toBe('Intro'); + expect(chapters[0].text).toContain('Welcome text.'); + expect(chapters[0].text).not.toContain('Header line'); + expect(chapters[1].title).toBe('Second'); + expect(chapters[1].text).toContain('More body.'); + }); +}); diff --git a/tests/unit/pdf-build-page-text.spec.ts b/tests/unit/pdf-build-page-text.spec.ts new file mode 100644 index 0000000..e5b8ac2 --- /dev/null +++ b/tests/unit/pdf-build-page-text.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { buildPageTextFromBlocks } from '../../src/lib/client/pdf-block-text'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +test.describe('buildPageTextFromBlocks', () => { + test('filters skipped kinds and preserves reading order', () => { + const page: ParsedPdfPage = { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b2', + kind: 'page-header', + text: 'Copyright Header', + fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }], + }, + { + id: 'b1', + kind: 'paragraph', + text: 'Body text', + fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'caption', + text: 'Figure caption', + fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }], + }, + ], + }; + + expect(buildPageTextFromBlocks(page, ['page-header'])).toBe('Body text Figure caption'); + expect(buildPageTextFromBlocks(page, ['page-header', 'caption'])).toBe('Body text'); + }); +}); diff --git a/tests/unit/pdf-merge-text-with-regions.spec.ts b/tests/unit/pdf-merge-text-with-regions.spec.ts new file mode 100644 index 0000000..09be6dc --- /dev/null +++ b/tests/unit/pdf-merge-text-with-regions.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { mergeTextWithRegions } from '../../src/lib/server/pdf-layout/mergeTextWithRegions'; + +test.describe('mergeTextWithRegions', () => { + test('assigns text items to containing regions by centroid and joins in reading order', () => { + const regions = [ + { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'paragraph' as const }, + { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'caption' as const }, + ]; + + const textItems = [ + { text: 'world', x: 40, y: 20, width: 20, height: 8 }, + { text: 'hello', x: 10, y: 20, width: 20, height: 8 }, + { text: 'Figure', x: 10, y: 70, width: 24, height: 8 }, + { text: '1.2', x: 40, y: 70, width: 10, height: 8 }, + ]; + + const merged = mergeTextWithRegions(regions, textItems); + expect(merged).toHaveLength(2); + expect(merged[0].text).toBe('hello world'); + expect(merged[1].text).toBe('Figure 1.2'); + }); +}); diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts new file mode 100644 index 0000000..2a0ff5d --- /dev/null +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { stitchCrossPageBlocks } from '../../src/lib/server/pdf-layout/stitchCrossPageBlocks'; +import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf'; + +function makeBlock( + id: string, + kind: ParsedPdfBlockKind, + text: string, + page: number, + readingOrder: number, +): ParsedPdfBlock { + return { + id, + kind, + text, + fragments: [{ + page, + bbox: [0, 0, 100, 10], + text, + readingOrder, + }], + }; +} + +function makeDoc(page1Blocks: ParsedPdfBlock[], page2Blocks: ParsedPdfBlock[]): ParsedPdfDocument { + return { + schemaVersion: 1, + documentId: 'doc', + parserVersion: 'test', + parsedAt: 0, + pages: [ + { pageNumber: 1, width: 100, height: 100, blocks: page1Blocks }, + { pageNumber: 2, width: 100, height: 100, blocks: page2Blocks }, + ], + }; +} + +test.describe('stitchCrossPageBlocks', () => { + test('stitches paragraph continuation across footer/header noise', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), + makeBlock('b2', 'page-footer', 'Footer text', 1, 1), + ], + [ + makeBlock('b3', 'page-header', 'Header text', 2, 0), + makeBlock('b4', 'paragraph', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); + }); + + test('does not stitch across section-header boundary', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'section-header', '2 New Section', 2, 0), + makeBlock('b3', 'paragraph', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2', 'b3']); + }); + + test('does not stitch when tail has sentence terminal', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence is complete.', 1, 0), + ], + [ + makeBlock('b2', 'paragraph', 'next sentence starts here', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']); + }); +}); + diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index 22681ca..c349897 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -20,6 +20,8 @@ test.describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, + parse_status TEXT, + parsed_json_key TEXT, created_at INTEGER, PRIMARY KEY (id, user_id) ); From 37a90b734de953989f8c16bd0aeb52fa42259cc3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 18 May 2026 04:26:39 -0600 Subject: [PATCH 002/137] feat(pdf): migrate to PP-DocLayoutV3 ONNX model and update PDF block taxonomy Switch PDF layout parsing to use the PP-DocLayoutV3 ONNX model, replacing the previous Docling-based approach. Update all environment variables, model fetching, and manifest handling for the new model and its artifacts. Refactor block kind taxonomy throughout the codebase, tests, and UI to align with PP-DocLayoutV3 labels, including expanded and renamed block types. Revise document settings, block filtering, and stitching logic to support the new set of block kinds. Update documentation and environment variable references to reflect the model transition. BREAKING CHANGE: PDF parsing now requires PP-DocLayoutV3 ONNX model and updated environment variables; block kind names and settings have changed throughout the system. --- .env.example | 7 +- Dockerfile | 2 +- README.md | 2 +- docs-site/docs/deploy/local-development.md | 2 +- .../docs/reference/environment-variables.md | 31 +- scripts/fetch-models.mjs | 30 +- src/components/documents/DocumentSettings.tsx | 25 +- src/components/views/PDFViewer.tsx | 26 +- src/lib/client/audiobooks/adapters/pdf.ts | 4 +- src/lib/server/pdf-layout/ensureModel.ts | 69 +++-- .../server/pdf-layout/mergeTextWithRegions.ts | 24 +- src/lib/server/pdf-layout/model/LICENSE.txt | 6 +- src/lib/server/pdf-layout/model/manifest.json | 26 +- src/lib/server/pdf-layout/parsePdf.ts | 2 +- src/lib/server/pdf-layout/runLayoutModel.ts | 282 ++++++++++++------ .../pdf-layout/stitchCrossPageBlocks.ts | 21 +- src/lib/shared/document-settings.ts | 18 +- src/types/document-settings.ts | 2 +- src/types/parsed-pdf.ts | 54 +++- tests/unit/pdf-audiobook-adapter.spec.ts | 14 +- tests/unit/pdf-build-page-text.spec.ts | 10 +- .../unit/pdf-merge-text-with-regions.spec.ts | 4 +- .../unit/pdf-stitch-cross-page-blocks.spec.ts | 21 +- 23 files changed, 464 insertions(+), 218 deletions(-) diff --git a/.env.example b/.env.example index 8115116..ec650c1 100644 --- a/.env.example +++ b/.env.example @@ -88,8 +88,11 @@ OPENREADER_COMPUTE_MODE=local # OPENREADER_COMPUTE_WORKER_URL= # OPENREADER_COMPUTE_WORKER_TOKEN= -# Optional override for Docling layout model download URL -# OPENREADER_DOCLING_MODEL_URL=https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx +# Optional overrides for PDF layout model artifacts +# OPENREADER_PDF_LAYOUT_MODEL_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx +# OPENREADER_PDF_LAYOUT_MODEL_DATA_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data +# OPENREADER_PDF_LAYOUT_CONFIG_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json +# OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json # (Optional) Override ffmpeg binary path used for audiobook processing FFMPEG_BIN= diff --git a/Dockerfile b/Dockerfile index a4d6c81..764b387 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,7 @@ COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses # Include SeaweedFS license text for the copied weed binary. COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt # Include static model notices for runtime-downloaded assets. -COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/docling-layout-LICENSE.txt +COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt # Copy the compiled whisper.cpp build output into the runtime image # (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) diff --git a/README.md b/README.md index 22e0590..210e215 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. - ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`). -- 🧱 **Layout-aware PDF parsing** (Docling ONNX) with structured blocks for cleaner TTS/chaptering. +- 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 93d144f..875c83f 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -261,7 +261,7 @@ For all environment variables, see [Environment Variables](../reference/environm ::: :::tip Optional model prefetch -To pre-populate the Docling ONNX model cache before first PDF parse, run `pnpm fetch-models`. +To pre-populate the PDF layout ONNX model cache before first PDF parse, run `pnpm fetch-models`. ::: See [Auth](../configure/auth) for app/auth behavior. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index e023ba8..f9d43cc 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -56,7 +56,10 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing | | `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | | `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | -| `OPENREADER_DOCLING_MODEL_URL` | PDF layout model | Docling HF URL | Override ONNX download URL for `ensureModel()` | +| `OPENREADER_PDF_LAYOUT_MODEL_URL` | PDF layout model | PP-DocLayoutV3 ONNX URL | Override ONNX model URL for `ensureModel()` | +| `OPENREADER_PDF_LAYOUT_MODEL_DATA_URL` | PDF layout model | PP-DocLayoutV3 ONNX data URL | Override ONNX external data URL for `ensureModel()` | +| `OPENREADER_PDF_LAYOUT_CONFIG_URL` | PDF layout model | PP-DocLayoutV3 config URL | Override model config URL for `ensureModel()` | +| `OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL` | PDF layout model | PP-DocLayoutV3 preprocessor URL | Override model preprocessor URL for `ensureModel()` | | `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -374,13 +377,31 @@ Reserved bearer token for future external compute worker mode. - Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) - Leave unset in v1 -### OPENREADER_DOCLING_MODEL_URL +### OPENREADER_PDF_LAYOUT_MODEL_URL -Override URL for the Docling ONNX layout model downloaded by `ensureModel()`. +Override URL for the PP-DocLayoutV3 ONNX model downloaded by `ensureModel()`. -- Default: `https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx` +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx` - Optional for custom mirrors/air-gapped workflows -- You can also pre-populate the model cache via `pnpm fetch-models` + +### OPENREADER_PDF_LAYOUT_MODEL_DATA_URL + +Override URL for the PP-DocLayoutV3 ONNX external data file downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data` + +### OPENREADER_PDF_LAYOUT_CONFIG_URL + +Override URL for the PP-DocLayoutV3 `config.json` downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json` + +### OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL + +Override URL for the PP-DocLayoutV3 `preprocessor_config.json` downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json` +- You can pre-populate the model cache via `pnpm fetch-models` ### WHISPER_CPP_BIN diff --git a/scripts/fetch-models.mjs b/scripts/fetch-models.mjs index 24cd5ac..edf56c2 100644 --- a/scripts/fetch-models.mjs +++ b/scripts/fetch-models.mjs @@ -3,13 +3,21 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; const modelDir = path.join(process.cwd(), 'docstore', 'model'); -const modelPath = path.join(modelDir, 'docling-layout-heron.onnx'); -const configPath = path.join(modelDir, 'docling-layout-heron.config.json'); -const licensePath = path.join(modelDir, 'docling-layout-heron.LICENSE.txt'); +const modelPath = path.join(modelDir, 'PP-DocLayoutV3.onnx'); +const modelDataPath = path.join(modelDir, 'PP-DocLayoutV3.onnx.data'); +const configPath = path.join(modelDir, 'pp-doclayoutv3.config.json'); +const preprocessorPath = path.join(modelDir, 'pp-doclayoutv3.preprocessor_config.json'); +const licensePath = path.join(modelDir, 'pp-doclayoutv3.LICENSE.txt'); const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt'); -const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; -const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; +const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL + || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx'; +const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL + || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data'; +const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL + || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json'; +const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL + || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json'; await mkdir(modelDir, { recursive: true }); @@ -19,12 +27,24 @@ if (!modelRes.ok) { } await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer())); +const modelDataRes = await fetch(modelDataUrl); +if (!modelDataRes.ok) { + throw new Error(`Failed to fetch model data: ${modelDataRes.status} ${modelDataRes.statusText}`); +} +await writeFile(modelDataPath, new Uint8Array(await modelDataRes.arrayBuffer())); + const configRes = await fetch(configUrl); if (!configRes.ok) { throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`); } await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer())); +const preprocessorRes = await fetch(preprocessorUrl); +if (!preprocessorRes.ok) { + throw new Error(`Failed to fetch preprocessor config: ${preprocessorRes.status} ${preprocessorRes.statusText}`); +} +await writeFile(preprocessorPath, new Uint8Array(await preprocessorRes.arrayBuffer())); + const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath)); await writeFile(licensePath, staticLicense); diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index d63916b..bdb534c 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -21,10 +21,27 @@ import { RefreshIcon } from '@/components/icons/Icons'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [ - { kind: 'page-header', label: 'Page headers' }, - { kind: 'page-footer', label: 'Page footers' }, - { kind: 'footnote', label: 'Footnotes' }, - { kind: 'caption', label: 'Captions' }, + { kind: 'header', label: 'Header' }, + { kind: 'footer', label: 'Footer' }, + { kind: 'footnote', label: 'Footnote' }, + { kind: 'vision_footnote', label: 'Vision footnote' }, + { kind: 'figure_title', label: 'Figure title' }, + { kind: 'doc_title', label: 'Document title' }, + { kind: 'paragraph_title', label: 'Paragraph title' }, + { kind: 'abstract', label: 'Abstract' }, + { kind: 'algorithm', label: 'Algorithm' }, + { kind: 'aside_text', label: 'Aside text' }, + { kind: 'content', label: 'Content' }, + { kind: 'reference', label: 'Reference' }, + { kind: 'reference_content', label: 'Reference content' }, + { kind: 'text', label: 'Text' }, + { kind: 'number', label: 'Number' }, + { kind: 'formula', label: 'Formula' }, + { kind: 'formula_number', label: 'Formula number' }, + { kind: 'table', label: 'Table' }, + { kind: 'chart', label: 'Chart' }, + { kind: 'image', label: 'Image' }, + { kind: 'seal', label: 'Seal' }, ]; const viewTypeTextMapping = [ diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 1cc8e1a..b11447e 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -354,14 +354,26 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { const colorForKind = (kind: ParsedPdfBlock['kind']): string => { switch (kind) { - case 'section-header': return 'rgba(34,197,94,0.20)'; - case 'title': return 'rgba(16,185,129,0.20)'; - case 'caption': return 'rgba(245,158,11,0.20)'; + case 'paragraph_title': return 'rgba(34,197,94,0.20)'; + case 'doc_title': return 'rgba(16,185,129,0.20)'; + case 'figure_title': return 'rgba(245,158,11,0.20)'; case 'table': return 'rgba(59,130,246,0.20)'; - case 'picture': return 'rgba(139,92,246,0.20)'; - case 'page-header': - case 'page-footer': - case 'footnote': return 'rgba(239,68,68,0.20)'; + case 'chart': + case 'image': return 'rgba(139,92,246,0.20)'; + case 'header': + case 'footer': + case 'footnote': + case 'vision_footnote': return 'rgba(239,68,68,0.20)'; + case 'formula': + case 'formula_number': return 'rgba(251,146,60,0.22)'; + case 'abstract': + case 'algorithm': + case 'aside_text': + case 'content': + case 'reference': + case 'reference_content': + case 'text': + case 'number': return 'rgba(14,165,233,0.18)'; default: return 'rgba(14,165,233,0.18)'; } }; diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 4b52581..6c38631 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -60,6 +60,8 @@ function prepareParsedChapters({ let currentTitle = 'Introduction'; let currentBlocks: ParsedPdfBlock[] = []; + const chapterBoundaryKinds = new Set(['paragraph_title', 'doc_title']); + const flush = () => { if (!currentBlocks.length) return; const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); @@ -74,7 +76,7 @@ function prepareParsedChapters({ }; for (const block of allBlocks) { - if (block.kind === 'section-header') { + if (chapterBoundaryKinds.has(block.kind)) { flush(); currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`; currentBlocks.push(block); diff --git a/src/lib/server/pdf-layout/ensureModel.ts b/src/lib/server/pdf-layout/ensureModel.ts index 70d5c73..e39dd62 100644 --- a/src/lib/server/pdf-layout/ensureModel.ts +++ b/src/lib/server/pdf-layout/ensureModel.ts @@ -4,12 +4,16 @@ import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; import manifest from '@/lib/server/pdf-layout/model/manifest.json'; -const DEFAULT_MODEL_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; -const DEFAULT_CONFIG_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; +const DEFAULT_MODEL_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx'; +const DEFAULT_MODEL_DATA_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data'; +const DEFAULT_CONFIG_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json'; +const DEFAULT_PREPROCESSOR_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json'; const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); -const MODEL_PATH = path.join(MODEL_DIR, 'docling-layout-heron.onnx'); -const CONFIG_PATH = path.join(MODEL_DIR, 'docling-layout-heron.config.json'); -const LICENSE_PATH = path.join(MODEL_DIR, 'docling-layout-heron.LICENSE.txt'); +export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx'); +export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data'); +export const MODEL_CONFIG_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.config.json'); +export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.preprocessor_config.json'); +const LICENSE_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.LICENSE.txt'); const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/pdf-layout/model/LICENSE.txt'); let inflight: Promise | null = null; @@ -51,15 +55,22 @@ async function verifyFile(pathToFile: string, manifestPath: string): Promise { await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { - throw new Error('Docling model license checksum verification failed'); + throw new Error('PDF layout model license checksum verification failed'); } } async function ensureModelInternal(): Promise { try { await access(MODEL_PATH); - await access(CONFIG_PATH); - if (await verifyFile(MODEL_PATH, 'model.onnx') && await verifyFile(CONFIG_PATH, 'config.json')) { + await access(MODEL_DATA_PATH); + await access(MODEL_CONFIG_PATH); + await access(MODEL_PREPROCESSOR_PATH); + if ( + await verifyFile(MODEL_PATH, 'model.onnx') + && await verifyFile(MODEL_DATA_PATH, 'model.onnx.data') + && await verifyFile(MODEL_CONFIG_PATH, 'config.json') + && await verifyFile(MODEL_PREPROCESSOR_PATH, 'preprocessor_config.json') + ) { await ensureLicense(); return MODEL_PATH; } @@ -68,24 +79,44 @@ async function ensureModelInternal(): Promise { } await mkdir(MODEL_DIR, { recursive: true }); - const tmpPath = `${MODEL_PATH}.tmp`; - const configTmpPath = `${CONFIG_PATH}.tmp`; - const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL?.trim() || DEFAULT_MODEL_URL; - const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL?.trim() || DEFAULT_CONFIG_URL; + const modelTmpPath = `${MODEL_PATH}.tmp`; + const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`; + const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`; + const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`; + const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL?.trim() + || DEFAULT_MODEL_URL; + const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL?.trim() + || DEFAULT_MODEL_DATA_URL; + const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL?.trim() + || DEFAULT_CONFIG_URL; + const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL?.trim() + || DEFAULT_PREPROCESSOR_URL; - await downloadToFile(modelUrl, tmpPath); - if (!(await verifyFile(tmpPath, 'model.onnx'))) { - await unlink(tmpPath).catch(() => undefined); - throw new Error('Docling model checksum verification failed'); + await downloadToFile(modelUrl, modelTmpPath); + if (!(await verifyFile(modelTmpPath, 'model.onnx'))) { + await unlink(modelTmpPath).catch(() => undefined); + throw new Error('PDF layout model checksum verification failed'); + } + await downloadToFile(modelDataUrl, modelDataTmpPath); + if (!(await verifyFile(modelDataTmpPath, 'model.onnx.data'))) { + await unlink(modelDataTmpPath).catch(() => undefined); + throw new Error('PDF layout model external data checksum verification failed'); } await downloadToFile(configUrl, configTmpPath); if (!(await verifyFile(configTmpPath, 'config.json'))) { await unlink(configTmpPath).catch(() => undefined); - throw new Error('Docling model config checksum verification failed'); + throw new Error('PDF layout model config checksum verification failed'); + } + await downloadToFile(preprocessorUrl, preprocessorTmpPath); + if (!(await verifyFile(preprocessorTmpPath, 'preprocessor_config.json'))) { + await unlink(preprocessorTmpPath).catch(() => undefined); + throw new Error('PDF layout model preprocessor checksum verification failed'); } - await rename(tmpPath, MODEL_PATH); - await rename(configTmpPath, CONFIG_PATH); + await rename(modelTmpPath, MODEL_PATH); + await rename(modelDataTmpPath, MODEL_DATA_PATH); + await rename(configTmpPath, MODEL_CONFIG_PATH); + await rename(preprocessorTmpPath, MODEL_PREPROCESSOR_PATH); await ensureLicense(); return MODEL_PATH; } diff --git a/src/lib/server/pdf-layout/mergeTextWithRegions.ts b/src/lib/server/pdf-layout/mergeTextWithRegions.ts index 8b56834..08e0743 100644 --- a/src/lib/server/pdf-layout/mergeTextWithRegions.ts +++ b/src/lib/server/pdf-layout/mergeTextWithRegions.ts @@ -1,15 +1,23 @@ import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; -const NON_TEXT_REGION_LABELS = new Set(['picture', 'table']); +const NON_TEXT_REGION_LABELS = new Set(['chart', 'image', 'table', 'seal']); const TEXT_ASSIGNABLE_LABELS = new Set([ - 'title', - 'section-header', - 'paragraph', - 'list-item', - 'caption', - 'page-header', - 'page-footer', + 'abstract', + 'algorithm', + 'aside_text', + 'content', + 'doc_title', + 'figure_title', + 'footer', 'footnote', + 'formula_number', + 'header', + 'number', + 'paragraph_title', + 'reference', + 'reference_content', + 'text', + 'vision_footnote', 'formula', ]); diff --git a/src/lib/server/pdf-layout/model/LICENSE.txt b/src/lib/server/pdf-layout/model/LICENSE.txt index d2b2034..cb60fe1 100644 --- a/src/lib/server/pdf-layout/model/LICENSE.txt +++ b/src/lib/server/pdf-layout/model/LICENSE.txt @@ -1,3 +1,3 @@ -Docling layout model assets are distributed under Apache-2.0 / CDLA terms by the model publisher. -See the upstream release for the authoritative license text: -https://huggingface.co/ds4sd/docling-layout-heron +PP-DocLayoutV3 ONNX model assets are distributed by the model publisher under Apache-2.0. +See upstream for the authoritative license and terms: +https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX diff --git a/src/lib/server/pdf-layout/model/manifest.json b/src/lib/server/pdf-layout/model/manifest.json index c688e28..9f4807d 100644 --- a/src/lib/server/pdf-layout/model/manifest.json +++ b/src/lib/server/pdf-layout/model/manifest.json @@ -1,21 +1,31 @@ { - "name": "docling-layout-heron", - "version": "docling-project/docling-layout-heron-onnx@main", + "name": "pp-doclayoutv3", + "version": "Bei0001/PP-DocLayoutV3-ONNX@main", "files": [ { "path": "model.onnx", - "sha256": "59c81a3a2923042d85034ffc487f8f47e4854117e879aef89b2b9f728fb4922a", - "size": 171220471 + "sha256": "c0721928ff08741bb208ebed539c77170db5234a68cb7e546e6cc9bc172a695b", + "size": 5088167 + }, + { + "path": "model.onnx.data", + "sha256": "34df3e4b79d7bbbf82abce1b4f3cde3d540fa57ad42ec8905c352b97c408d437", + "size": 136774480 }, { "path": "config.json", - "sha256": "c6e67b6cdf64fa245779d0409bb994aa96e8c1790e15ec07a65843628e232180", - "size": 878 + "sha256": "3cf834b91d23a756b1519bce4db42c09e852f3e35c35092dd5a3e253a50c071a", + "size": 2460 + }, + { + "path": "preprocessor_config.json", + "sha256": "519fe0187a43a1ca429e3ad8317bab8700f0d5e8fb3a6e3a0a413ffac078ba42", + "size": 575 }, { "path": "LICENSE.txt", - "sha256": "8f5030e085c59609746fce1010230f12b4a565abb6bdc19a2c2d3735ba1710d9", - "size": 209 + "sha256": "578a6ba6f86b0692a8f719843f575a3eebf4705768ac5c37d149f441208f601f", + "size": 195 } ] } diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts index e95cc35..84d1703 100644 --- a/src/lib/server/pdf-layout/parsePdf.ts +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -140,7 +140,7 @@ export async function parsePdf(input: ParsePdfInput): Promise const doc: ParsedPdfDocument = { schemaVersion: 1, documentId: input.documentId, - parserVersion: 'docling-layout-heron@v1+pdfjs@4.8.69', + parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69', parsedAt: Date.now(), pages, }; diff --git a/src/lib/server/pdf-layout/runLayoutModel.ts b/src/lib/server/pdf-layout/runLayoutModel.ts index 608de35..7407ea0 100644 --- a/src/lib/server/pdf-layout/runLayoutModel.ts +++ b/src/lib/server/pdf-layout/runLayoutModel.ts @@ -1,8 +1,7 @@ import * as ort from 'onnxruntime-node'; import { readFile } from 'fs/promises'; -import path from 'path'; import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; -import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; +import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from '@/lib/server/pdf-layout/ensureModel'; interface RunLayoutInput { pageWidth: number; @@ -11,42 +10,74 @@ interface RunLayoutInput { pagePng: Buffer; } -const INPUT_SIZE = 640; -const MIN_SCORE = 0.6; +const DEFAULT_INPUT_SIZE = 800; +const MIN_SCORE = 0.5; +const CLASS_MIN_SCORE: Partial> = { + header: 0.4, + footer: 0.4, + figure_title: 0.45, + footnote: 0.45, + vision_footnote: 0.45, +}; const LABEL_MAP: Record = { - caption: 'caption', + // PP-DocLayoutV3 labels + abstract: 'abstract', + algorithm: 'algorithm', + aside_text: 'aside_text', + chart: 'chart', + content: 'content', + display_formula: 'formula', + doc_title: 'doc_title', + figure_title: 'figure_title', + footer: 'footer', + footer_image: 'footer', footnote: 'footnote', - formula: 'formula', - list_item: 'list-item', - page_footer: 'page-footer', - page_header: 'page-header', - picture: 'picture', - section_header: 'section-header', + formula_number: 'formula_number', + header: 'header', + header_image: 'header', + image: 'image', + inline_formula: 'formula', + number: 'number', + paragraph_title: 'paragraph_title', + reference: 'reference', + reference_content: 'reference_content', + seal: 'seal', table: 'table', - text: 'paragraph', - title: 'title', - document_index: null, - code: null, - checkbox_selected: null, - checkbox_unselected: null, - form: null, - key_value_region: null, + text: 'text', + vertical_text: 'text', + vision_footnote: 'vision_footnote', }; const MIN_REGION_SIZE: Partial> = { - paragraph: { minWidth: 24, minHeight: 14 }, - 'section-header': { minWidth: 24, minHeight: 14 }, - title: { minWidth: 24, minHeight: 14 }, - 'list-item': { minWidth: 18, minHeight: 12 }, - caption: { minWidth: 18, minHeight: 10 }, + abstract: { minWidth: 24, minHeight: 14 }, + algorithm: { minWidth: 24, minHeight: 14 }, + aside_text: { minWidth: 24, minHeight: 14 }, + content: { minWidth: 24, minHeight: 14 }, + text: { minWidth: 24, minHeight: 14 }, + reference: { minWidth: 24, minHeight: 14 }, + reference_content: { minWidth: 24, minHeight: 14 }, + paragraph_title: { minWidth: 24, minHeight: 14 }, + doc_title: { minWidth: 24, minHeight: 14 }, + number: { minWidth: 18, minHeight: 12 }, + figure_title: { minWidth: 18, minHeight: 10 }, footnote: { minWidth: 18, minHeight: 10 }, - 'page-header': { minWidth: 18, minHeight: 10 }, - 'page-footer': { minWidth: 18, minHeight: 10 }, + vision_footnote: { minWidth: 18, minHeight: 10 }, + header: { minWidth: 18, minHeight: 10 }, + footer: { minWidth: 18, minHeight: 10 }, }; +interface ModelPreprocessor { + inputWidth: number; + inputHeight: number; + rescaleFactor: number; + mean: [number, number, number]; + std: [number, number, number]; +} + let sessionPromise: Promise | null = null; let idToLabelPromise: Promise> | null = null; +let preprocessorPromise: Promise | null = null; let canvasFnsPromise: Promise<{ createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; @@ -96,14 +127,13 @@ async function getSession(): Promise { async function getIdToLabel(): Promise> { if (!idToLabelPromise) { idToLabelPromise = (async () => { - const modelPath = await ensureModel(); - const configPath = path.join(path.dirname(modelPath), 'docling-layout-heron.config.json'); - const raw = await readFile(configPath, 'utf8'); + await ensureModel(); + const raw = await readFile(MODEL_CONFIG_PATH, 'utf8'); const parsed = JSON.parse(raw) as { id2label?: Record }; const out: Record = {}; for (const [key, value] of Object.entries(parsed.id2label ?? {})) { const n = Number(key); - if (Number.isFinite(n)) out[n] = value; + if (Number.isFinite(n)) out[n] = String(value ?? '').trim(); } return out; })(); @@ -111,49 +141,74 @@ async function getIdToLabel(): Promise> { return idToLabelPromise; } -function preprocessLetterboxed( - image: CanvasImageSource, - createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, -): { - tensor: ort.Tensor; - scale: number; - padX: number; - padY: number; -} { - const sourceWidth = Math.max(1, Number((image as { width?: number }).width ?? INPUT_SIZE)); - const sourceHeight = Math.max(1, Number((image as { height?: number }).height ?? INPUT_SIZE)); - const scale = Math.min(INPUT_SIZE / sourceWidth, INPUT_SIZE / sourceHeight); - const drawWidth = Math.max(1, Math.round(sourceWidth * scale)); - const drawHeight = Math.max(1, Math.round(sourceHeight * scale)); - const padX = Math.floor((INPUT_SIZE - drawWidth) / 2); - const padY = Math.floor((INPUT_SIZE - drawHeight) / 2); +async function getPreprocessor(): Promise { + if (!preprocessorPromise) { + preprocessorPromise = (async () => { + await ensureModel(); + const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8'); + const parsed = JSON.parse(raw) as { + size?: { width?: number; height?: number }; + rescale_factor?: number; + image_mean?: number[]; + image_std?: number[]; + }; - const canvas = createCanvasFn(INPUT_SIZE, INPUT_SIZE); + const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE)); + const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE)); + const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255); + const mean = [ + Number(parsed.image_mean?.[0] ?? 0), + Number(parsed.image_mean?.[1] ?? 0), + Number(parsed.image_mean?.[2] ?? 0), + ] as [number, number, number]; + const std = [ + Number(parsed.image_std?.[0] ?? 1), + Number(parsed.image_std?.[1] ?? 1), + Number(parsed.image_std?.[2] ?? 1), + ] as [number, number, number]; + + return { + inputWidth, + inputHeight, + rescaleFactor, + mean, + std, + }; + })(); + } + return preprocessorPromise; +} + +function preprocessResized( + image: CanvasImageSource, + preprocessor: ModelPreprocessor, + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, +): ort.Tensor { + const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE); + ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(image, padX, padY, drawWidth, drawHeight); + ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight); - const imageData = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); - const data = new Uint8Array(1 * 3 * INPUT_SIZE * INPUT_SIZE); - for (let y = 0; y < INPUT_SIZE; y += 1) { - for (let x = 0; x < INPUT_SIZE; x += 1) { - const pixelIndex = (y * INPUT_SIZE + x) * 4; - const chwIndex = y * INPUT_SIZE + x; - data[0 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex]; - data[1 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 1]; - data[2 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 2]; + const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight); + const channelSize = preprocessor.inputWidth * preprocessor.inputHeight; + for (let y = 0; y < preprocessor.inputHeight; y += 1) { + for (let x = 0; x < preprocessor.inputWidth; x += 1) { + const pixelIndex = (y * preprocessor.inputWidth + x) * 4; + const idx = y * preprocessor.inputWidth + x; + const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor; + const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor; + const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor; + + chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]); + chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]); + chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]); } } - - return { - tensor: new ort.Tensor('uint8', data, [1, 3, INPUT_SIZE, INPUT_SIZE]), - scale, - padX, - padY, - }; + return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]); } function clampBox( @@ -169,6 +224,36 @@ function clampBox( return [x0, y0, x1, y1]; } +function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } { + let maxLogit = Number.NEGATIVE_INFINITY; + let maxIndex = 0; + for (let i = 0; i < count; i += 1) { + const value = logits[offset + i]; + if (value > maxLogit) { + maxLogit = value; + maxIndex = i; + } + } + + let sum = 0; + for (let i = 0; i < count; i += 1) { + sum += Math.exp(logits[offset + i] - maxLogit); + } + + const score = sum > 0 ? (1 / sum) : 0; + return { index: maxIndex, score }; +} + +function normalizeModelLabel(rawLabel: string): string { + const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (normalized.endsWith('_image')) { + const base = normalized.slice(0, -'_image'.length); + if (base === 'header' || base === 'footer') return normalized; + } + // Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1). + return normalized.replace(/_\d+$/g, ''); +} + export async function runLayoutModel(input: RunLayoutInput): Promise { const { pageWidth, pageHeight, textItems, pagePng } = input; if (!textItems.length) return []; @@ -177,44 +262,49 @@ export async function runLayoutModel(input: RunLayoutInput): Promise([ + 'text', + 'content', + 'reference_content', + 'aside_text', + 'abstract', + 'algorithm', + 'reference', +]); + function stripTrailingClosers(text: string): string { return text.trim().replace(/[\"'”’\]\)]+$/g, ''); } @@ -9,7 +19,7 @@ function isSentenceTerminal(text: string): boolean { } function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { - if (!['paragraph', 'list-item'].includes(a.kind)) return false; + if (!STITCHABLE_KINDS.has(a.kind)) return false; if (a.kind !== b.kind) return false; if (isSentenceTerminal(a.text)) return false; const next = b.text.trim(); @@ -18,13 +28,16 @@ function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { return true; } -const HARD_BOUNDARY_KINDS = new Set(['section-header', 'title']); +const HARD_BOUNDARY_KINDS = new Set([ + 'paragraph_title', + 'doc_title', +]); function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { for (let i = blocks.length - 1; i >= 0; i -= 1) { const block = blocks[i]; if (!block || !block.text.trim()) continue; - if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + if (STITCHABLE_KINDS.has(block.kind)) return i; } return -1; } @@ -33,7 +46,7 @@ function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number { for (let i = 0; i < blocks.length; i += 1) { const block = blocks[i]; if (!block || !block.text.trim()) continue; - if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + if (STITCHABLE_KINDS.has(block.kind)) return i; } return -1; } diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts index d22ef6c..537038a 100644 --- a/src/lib/shared/document-settings.ts +++ b/src/lib/shared/document-settings.ts @@ -2,25 +2,11 @@ import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings, } from '@/types/document-settings'; -import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; - -const PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [ - 'title', - 'section-header', - 'paragraph', - 'list-item', - 'caption', - 'table', - 'picture', - 'page-header', - 'page-footer', - 'footnote', - 'formula', -]; +import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf'; function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])]; - const allow = new Set(PDF_BLOCK_KINDS); + const allow = new Set(PARSED_PDF_BLOCK_KINDS); const out: ParsedPdfBlockKind[] = []; for (const entry of value) { if (typeof entry !== 'string') continue; diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts index bb591ad..0262ed5 100644 --- a/src/types/document-settings.ts +++ b/src/types/document-settings.ts @@ -12,7 +12,7 @@ export interface DocumentSettings { export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { schemaVersion: 1, pdf: { - skipBlockKinds: ['page-header', 'page-footer', 'footnote'], + skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'], chaptersFromSections: true, }, }; diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts index f64f126..c1c1daa 100644 --- a/src/types/parsed-pdf.ts +++ b/src/types/parsed-pdf.ts @@ -1,15 +1,49 @@ export type ParsedPdfBlockKind = - | 'title' - | 'section-header' - | 'paragraph' - | 'list-item' - | 'caption' - | 'table' - | 'picture' - | 'page-header' - | 'page-footer' + | 'abstract' + | 'algorithm' + | 'aside_text' + | 'chart' + | 'content' + | 'formula' + | 'doc_title' + | 'figure_title' + | 'footer' | 'footnote' - | 'formula'; + | 'formula_number' + | 'header' + | 'image' + | 'number' + | 'paragraph_title' + | 'reference' + | 'reference_content' + | 'seal' + | 'table' + | 'text' + | 'vision_footnote'; + +export const PARSED_PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [ + 'abstract', + 'algorithm', + 'aside_text', + 'chart', + 'content', + 'formula', + 'doc_title', + 'figure_title', + 'footer', + 'footnote', + 'formula_number', + 'header', + 'image', + 'number', + 'paragraph_title', + 'reference', + 'reference_content', + 'seal', + 'table', + 'text', + 'vision_footnote', +]; export interface ParsedPdfBlockFragment { page: number; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index d430401..cf357cf 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -4,7 +4,7 @@ import type { ParsedPdfDocument } from '../../src/types/parsed-pdf'; import type { DocumentSettings } from '../../src/types/document-settings'; test.describe('pdf audiobook adapter', () => { - test('builds chapters from section headers and filters skipped kinds', async () => { + test('builds chapters from paragraph titles and filters skipped kinds', async () => { const parsed: ParsedPdfDocument = { schemaVersion: 1, documentId: 'doc-1', @@ -18,31 +18,31 @@ test.describe('pdf audiobook adapter', () => { blocks: [ { id: 'b1', - kind: 'section-header', + kind: 'paragraph_title', text: 'Intro', fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }], }, { id: 'b2', - kind: 'paragraph', + kind: 'text', text: 'Welcome text.', fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }], }, { id: 'b3', - kind: 'page-header', + kind: 'header', text: 'Header line', fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }], }, { id: 'b4', - kind: 'section-header', + kind: 'paragraph_title', text: 'Second', fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }], }, { id: 'b5', - kind: 'paragraph', + kind: 'text', text: 'More body.', fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }], }, @@ -54,7 +54,7 @@ test.describe('pdf audiobook adapter', () => { const settings: DocumentSettings = { schemaVersion: 1, pdf: { - skipBlockKinds: ['page-header'], + skipBlockKinds: ['header'], chaptersFromSections: true, }, }; diff --git a/tests/unit/pdf-build-page-text.spec.ts b/tests/unit/pdf-build-page-text.spec.ts index e5b8ac2..247507c 100644 --- a/tests/unit/pdf-build-page-text.spec.ts +++ b/tests/unit/pdf-build-page-text.spec.ts @@ -11,26 +11,26 @@ test.describe('buildPageTextFromBlocks', () => { blocks: [ { id: 'b2', - kind: 'page-header', + kind: 'header', text: 'Copyright Header', fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }], }, { id: 'b1', - kind: 'paragraph', + kind: 'text', text: 'Body text', fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }], }, { id: 'b3', - kind: 'caption', + kind: 'figure_title', text: 'Figure caption', fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }], }, ], }; - expect(buildPageTextFromBlocks(page, ['page-header'])).toBe('Body text Figure caption'); - expect(buildPageTextFromBlocks(page, ['page-header', 'caption'])).toBe('Body text'); + expect(buildPageTextFromBlocks(page, ['header'])).toBe('Body text Figure caption'); + expect(buildPageTextFromBlocks(page, ['header', 'figure_title'])).toBe('Body text'); }); }); diff --git a/tests/unit/pdf-merge-text-with-regions.spec.ts b/tests/unit/pdf-merge-text-with-regions.spec.ts index 09be6dc..f0f8bb7 100644 --- a/tests/unit/pdf-merge-text-with-regions.spec.ts +++ b/tests/unit/pdf-merge-text-with-regions.spec.ts @@ -4,8 +4,8 @@ import { mergeTextWithRegions } from '../../src/lib/server/pdf-layout/mergeTextW test.describe('mergeTextWithRegions', () => { test('assigns text items to containing regions by centroid and joins in reading order', () => { const regions = [ - { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'paragraph' as const }, - { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'caption' as const }, + { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'text' as const }, + { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'figure_title' as const }, ]; const textItems = [ diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts index 2a0ff5d..cdc3ac2 100644 --- a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -39,12 +39,12 @@ test.describe('stitchCrossPageBlocks', () => { test('stitches paragraph continuation across footer/header noise', () => { const doc = makeDoc( [ - makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), - makeBlock('b2', 'page-footer', 'Footer text', 1, 1), + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + makeBlock('b2', 'footer', 'Footer text', 1, 1), ], [ - makeBlock('b3', 'page-header', 'Header text', 2, 0), - makeBlock('b4', 'paragraph', 'into the next page.', 2, 1), + makeBlock('b3', 'header', 'Header text', 2, 0), + makeBlock('b4', 'text', 'into the next page.', 2, 1), ], ); @@ -57,14 +57,14 @@ test.describe('stitchCrossPageBlocks', () => { expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); }); - test('does not stitch across section-header boundary', () => { + test('does not stitch across paragraph-title boundary', () => { const doc = makeDoc( [ - makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), + makeBlock('b1', 'text', 'This sentence continues', 1, 0), ], [ - makeBlock('b2', 'section-header', '2 New Section', 2, 0), - makeBlock('b3', 'paragraph', 'into the next page.', 2, 1), + makeBlock('b2', 'paragraph_title', '2 New Section', 2, 0), + makeBlock('b3', 'text', 'into the next page.', 2, 1), ], ); @@ -76,10 +76,10 @@ test.describe('stitchCrossPageBlocks', () => { test('does not stitch when tail has sentence terminal', () => { const doc = makeDoc( [ - makeBlock('b1', 'paragraph', 'This sentence is complete.', 1, 0), + makeBlock('b1', 'text', 'This sentence is complete.', 1, 0), ], [ - makeBlock('b2', 'paragraph', 'next sentence starts here', 2, 0), + makeBlock('b2', 'text', 'next sentence starts here', 2, 0), ], ); @@ -88,4 +88,3 @@ test.describe('stitchCrossPageBlocks', () => { expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']); }); }); - From 1544a2d9690d6fd643cd6e022b66f9a91ad48776 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 18 May 2026 05:20:20 -0600 Subject: [PATCH 003/137] refactor(previews): update preview variant defaults, cache schema, and image handling Remove hardcoded defaults for preview variant and width in database schemas to allow more flexible preview generation. Bump preview cache schema version and add versioning to cache rows for consistency. Switch preview image generation to 480px JPEGs with updated file naming and content type. Refactor PDF preview rendering to support configurable output format and quality, and update all preview consumers to use unified "image" buffer property instead of "png". Add migration scripts and update metadata for both Postgres and SQLite. BREAKING CHANGE: Preview variant and width defaults removed from database; preview cache and image handling updated across system. --- .../0006_preview-defaults-cleanup.sql | 2 + drizzle/postgres/meta/0006_snapshot.json | 1831 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + .../sqlite/0006_preview-defaults-cleanup.sql | 27 + drizzle/sqlite/meta/0006_snapshot.json | 1693 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + src/db/schema_postgres.ts | 4 +- src/db/schema_sqlite.ts | 4 +- src/lib/client/cache/previews.ts | 7 + src/lib/client/dexie.ts | 1 + .../server/documents/previews-blobstore.ts | 6 +- src/lib/server/documents/previews-render.ts | 145 +- src/lib/server/documents/previews.ts | 3 + src/lib/server/pdf-layout/parsePdf.ts | 2 +- src/lib/server/pdf-layout/renderPage.ts | 28 +- src/lib/server/pdf-layout/runLayoutModel.ts | 10 +- 16 files changed, 3634 insertions(+), 143 deletions(-) create mode 100644 drizzle/postgres/0006_preview-defaults-cleanup.sql create mode 100644 drizzle/postgres/meta/0006_snapshot.json create mode 100644 drizzle/sqlite/0006_preview-defaults-cleanup.sql create mode 100644 drizzle/sqlite/meta/0006_snapshot.json diff --git a/drizzle/postgres/0006_preview-defaults-cleanup.sql b/drizzle/postgres/0006_preview-defaults-cleanup.sql new file mode 100644 index 0000000..dddf603 --- /dev/null +++ b/drizzle/postgres/0006_preview-defaults-cleanup.sql @@ -0,0 +1,2 @@ +ALTER TABLE "document_previews" ALTER COLUMN "variant" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "document_previews" ALTER COLUMN "width" DROP DEFAULT; \ No newline at end of file diff --git a/drizzle/postgres/meta/0006_snapshot.json b/drizzle/postgres/meta/0006_snapshot.json new file mode 100644 index 0000000..f021826 --- /dev/null +++ b/drizzle/postgres/meta/0006_snapshot.json @@ -0,0 +1,1831 @@ +{ + "id": "4d83c2e9-61f5-4a0a-a49d-9b68e7912375", + "prevId": "a23bc220-ebf2-43e2-92b6-d765c23c07d4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 035979f..ee17326 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1779041718214, "tag": "0005_pdf_layout_and_compute", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1779102950715, + "tag": "0006_preview-defaults-cleanup", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0006_preview-defaults-cleanup.sql b/drizzle/sqlite/0006_preview-defaults-cleanup.sql new file mode 100644 index 0000000..4c92960 --- /dev/null +++ b/drizzle/sqlite/0006_preview-defaults-cleanup.sql @@ -0,0 +1,27 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_document_previews` ( + `document_id` text NOT NULL, + `namespace` text DEFAULT '' NOT NULL, + `variant` text NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `source_last_modified_ms` integer NOT NULL, + `object_key` text NOT NULL, + `content_type` text DEFAULT 'image/jpeg' NOT NULL, + `width` integer NOT NULL, + `height` integer, + `byte_size` integer, + `etag` text, + `lease_owner` text, + `lease_until_ms` integer DEFAULT 0 NOT NULL, + `attempt_count` integer DEFAULT 0 NOT NULL, + `last_error` text, + `created_at_ms` integer DEFAULT 0 NOT NULL, + `updated_at_ms` integer DEFAULT 0 NOT NULL, + PRIMARY KEY(`document_id`, `namespace`, `variant`) +); +--> statement-breakpoint +INSERT INTO `__new_document_previews`("document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms") SELECT "document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms" FROM `document_previews`;--> statement-breakpoint +DROP TABLE `document_previews`;--> statement-breakpoint +ALTER TABLE `__new_document_previews` RENAME TO `document_previews`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `idx_document_previews_status_lease` ON `document_previews` (`status`,`lease_until_ms`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0006_snapshot.json b/drizzle/sqlite/meta/0006_snapshot.json new file mode 100644 index 0000000..28a338a --- /dev/null +++ b/drizzle/sqlite/meta/0006_snapshot.json @@ -0,0 +1,1693 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4c531bb-7ddb-40e6-a946-815a6521653e", + "prevId": "bb7fa58d-9b33-46a5-81d1-d2cdfb88c8ee", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 3f75e71..32df1e6 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1779041717890, "tag": "0005_pdf_layout_and_compute", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1779102950385, + "tag": "0006_preview-defaults-cleanup", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index d87ef26..6de592f 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -103,12 +103,12 @@ export const userDocumentProgress = pgTable('user_document_progress', { export const documentPreviews = pgTable('document_previews', { documentId: text('document_id').notNull(), namespace: text('namespace').notNull().default(''), - variant: text('variant').notNull().default('card-240-jpeg'), + variant: text('variant').notNull(), status: text('status').notNull().default('queued'), sourceLastModifiedMs: bigint('source_last_modified_ms', { mode: 'number' }).notNull(), objectKey: text('object_key').notNull(), contentType: text('content_type').notNull().default('image/jpeg'), - width: integer('width').notNull().default(240), + width: integer('width').notNull(), height: integer('height'), byteSize: bigint('byte_size', { mode: 'number' }), eTag: text('etag'), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 1697a18..7bf0ade 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -103,12 +103,12 @@ export const userDocumentProgress = sqliteTable('user_document_progress', { export const documentPreviews = sqliteTable('document_previews', { documentId: text('document_id').notNull(), namespace: text('namespace').notNull().default(''), - variant: text('variant').notNull().default('card-240-jpeg'), + variant: text('variant').notNull(), status: text('status').notNull().default('queued'), sourceLastModifiedMs: integer('source_last_modified_ms').notNull(), objectKey: text('object_key').notNull(), contentType: text('content_type').notNull().default('image/jpeg'), - width: integer('width').notNull().default(240), + width: integer('width').notNull(), height: integer('height'), byteSize: integer('byte_size'), eTag: text('etag'), diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts index a5f91a9..d8ae889 100644 --- a/src/lib/client/cache/previews.ts +++ b/src/lib/client/cache/previews.ts @@ -8,6 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); +const PREVIEW_CACHE_SCHEMA_VERSION = 3; function revokeIfBlobUrl(url: string | null | undefined): void { if (!url) return; @@ -46,6 +47,11 @@ export async function getPersistedDocumentPreviewUrl( const row = await getDocumentPreviewCache(docId); if (!row) return null; + if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + if (Number(row.lastModified) !== Number(lastModified)) { await removeDocumentPreviewCache(docId).catch(() => {}); return null; @@ -102,6 +108,7 @@ export async function primeDocumentPreviewCache( contentType, data: bytes, cachedAt: Date.now(), + previewVersion: PREVIEW_CACHE_SCHEMA_VERSION, }); const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index 6f505ae..cf8e3ce 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -61,6 +61,7 @@ export interface DocumentPreviewCacheRow { data: ArrayBuffer; cachedAt: number; byteSize?: number; + previewVersion?: number; } export interface ConfigRow { diff --git a/src/lib/server/documents/previews-blobstore.ts b/src/lib/server/documents/previews-blobstore.ts index c69f86b..01d9ecd 100644 --- a/src/lib/server/documents/previews-blobstore.ts +++ b/src/lib/server/documents/previews-blobstore.ts @@ -10,10 +10,10 @@ import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const DEFAULT_NAMESPACE_SEGMENT = '_default'; -export const DOCUMENT_PREVIEW_VARIANT = 'card-240-jpeg'; -export const DOCUMENT_PREVIEW_FILE_NAME = 'card-240.jpg'; +export const DOCUMENT_PREVIEW_VARIANT = 'card-480-jpeg'; +export const DOCUMENT_PREVIEW_FILE_NAME = 'card-480.jpg'; export const DOCUMENT_PREVIEW_CONTENT_TYPE = 'image/jpeg'; -export const DOCUMENT_PREVIEW_WIDTH = 240; +export const DOCUMENT_PREVIEW_WIDTH = 480; function sanitizeNamespace(namespace: string | null): string | null { if (!namespace) return null; diff --git a/src/lib/server/documents/previews-render.ts b/src/lib/server/documents/previews-render.ts index 87415c5..be9bf86 100644 --- a/src/lib/server/documents/previews-render.ts +++ b/src/lib/server/documents/previews-render.ts @@ -1,7 +1,8 @@ import path from 'path'; -import { DOMMatrix, Path2D, createCanvas, loadImage } from '@napi-rs/canvas'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; import JSZip from 'jszip'; import { XMLParser } from 'fast-xml-parser'; +import { renderPage } from '@/lib/server/pdf-layout/renderPage'; export type RenderedDocumentPreview = { bytes: Buffer; @@ -9,26 +10,7 @@ export type RenderedDocumentPreview = { height: number; }; -type CanvasAndContext = { - canvas: unknown; - context: unknown; -}; - -type NodeCanvasFactory = { - create: (width: number, height: number) => CanvasAndContext; - reset: (target: CanvasAndContext, width: number, height: number) => void; - destroy: (target: CanvasAndContext) => void; -}; - -function ensureNodeCanvasGlobals(): void { - const g = globalThis as Record; - if (typeof g.DOMMatrix === 'undefined') { - g.DOMMatrix = DOMMatrix as unknown; - } - if (typeof g.Path2D === 'undefined') { - g.Path2D = Path2D as unknown; - } -} +const PREVIEW_JPEG_QUALITY = 82; function normalizeTargetWidth(targetWidth: number): number { if (!Number.isFinite(targetWidth) || targetWidth <= 0) return 240; @@ -99,9 +81,11 @@ async function renderImageBytesToJpeg(imageBytes: Buffer, targetWidth: number): const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, outWidth, outHeight); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, outWidth, outHeight); return { - bytes: canvas.toBuffer('image/jpeg', 82), + bytes: canvas.toBuffer('image/jpeg', PREVIEW_JPEG_QUALITY), width: outWidth, height: outHeight, }; @@ -150,109 +134,18 @@ export async function renderEpubCoverToJpeg(sourceBytes: Buffer, targetWidth: nu } export async function renderPdfFirstPageToJpeg(sourceBytes: Buffer, targetWidth: number): Promise { - ensureNodeCanvasGlobals(); - - type PdfViewport = { - width: number; - height: number; - }; - type PdfPage = { - getViewport: (params: { scale: number }) => PdfViewport; - render: (params: { - canvasContext: unknown; - viewport: PdfViewport; - intent: 'display'; - canvasFactory?: NodeCanvasFactory; - }) => { promise: Promise }; - }; - - // pdfjs-dist legacy build works in Node and avoids relying on DOM workers. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - pdfjs-dist legacy build path has no dedicated TypeScript declaration. - const pdfjs = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as { - getDocument: (options: Record) => { - promise: Promise<{ getPage: (n: number) => Promise; destroy: () => Promise }>; - destroy: () => Promise; - }; - GlobalWorkerOptions?: { workerSrc?: string; workerPort?: unknown }; - }; - - if (pdfjs.GlobalWorkerOptions) { - pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; - pdfjs.GlobalWorkerOptions.workerPort = null; - } - - const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); - const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; - - const nodeCanvasFactory: NodeCanvasFactory = { - create: (width, height) => { - const canvas = createCanvas(width, height); - const context = canvas.getContext('2d'); - return { canvas, context }; - }, - reset: (target, width, height) => { - const canvas = target.canvas as { width: number; height: number }; - canvas.width = width; - canvas.height = height; - }, - destroy: (target) => { - const canvas = target.canvas as { width: number; height: number }; - canvas.width = 0; - canvas.height = 0; - }, - }; - - class PdfNodeCanvasFactory { - create(width: number, height: number): CanvasAndContext { - return nodeCanvasFactory.create(width, height); - } - reset(target: CanvasAndContext, width: number, height: number): void { - nodeCanvasFactory.reset(target, width, height); - } - destroy(target: CanvasAndContext): void { - nodeCanvasFactory.destroy(target); - } - } - - const loadingTask = pdfjs.getDocument({ - data: new Uint8Array(sourceBytes), - useWorkerFetch: false, - standardFontDataUrl, - CanvasFactory: PdfNodeCanvasFactory, - isEvalSupported: false, + const width = normalizeTargetWidth(targetWidth); + const isolatedBytes = Uint8Array.from(sourceBytes); + const rendered = await renderPage({ + pdfBytes: isolatedBytes.buffer as ArrayBuffer, + pageNumber: 1, + targetWidth: width, + format: 'jpeg', + jpegQuality: PREVIEW_JPEG_QUALITY, }); - const pdf = await loadingTask.promise; - - try { - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1 }); - const target = normalizeTargetWidth(targetWidth); - const scale = target / viewport.width; - const scaledViewport = page.getViewport({ scale }); - - const outWidth = Math.max(1, Math.floor(scaledViewport.width)); - const outHeight = Math.max(1, Math.floor(scaledViewport.height)); - const canvas = createCanvas(outWidth, outHeight); - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, outWidth, outHeight); - - const renderTask = page.render({ - canvasContext: ctx, - viewport: scaledViewport, - intent: 'display', - canvasFactory: nodeCanvasFactory, - }); - await renderTask.promise; - - return { - bytes: canvas.toBuffer('image/jpeg', 82), - width: outWidth, - height: outHeight, - }; - } finally { - await pdf.destroy().catch(() => undefined); - await loadingTask.destroy().catch(() => undefined); - } + return { + bytes: rendered.image, + width: rendered.width, + height: rendered.height, + }; } diff --git a/src/lib/server/documents/previews.ts b/src/lib/server/documents/previews.ts index e73e382..8f4414c 100644 --- a/src/lib/server/documents/previews.ts +++ b/src/lib/server/documents/previews.ts @@ -306,6 +306,9 @@ async function generateAndStorePreview(doc: PreviewSourceDocument, namespace: st throw new Error(`Unsupported preview type: ${doc.type}`); } + // Replace-in-place semantics: clear old preview objects for this document + // prefix before writing the current variant so stale files don't linger. + await deleteDocumentPreviewArtifacts(doc.id, namespace).catch(() => undefined); await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); } finally { if (workDir) { diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts index 84d1703..6939a0a 100644 --- a/src/lib/server/pdf-layout/parsePdf.ts +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -99,7 +99,7 @@ export async function parsePdf(input: ParsePdfInput): Promise pageWidth: rendered.width, pageHeight: rendered.height, textItems: layoutTextItems, - pagePng: rendered.png, + pageImage: rendered.image, }); const merged = mergeTextWithRegions(regions, layoutTextItems); if (textItems.length > 0 && merged.length === 0) { diff --git a/src/lib/server/pdf-layout/renderPage.ts b/src/lib/server/pdf-layout/renderPage.ts index 0556176..e68fb66 100644 --- a/src/lib/server/pdf-layout/renderPage.ts +++ b/src/lib/server/pdf-layout/renderPage.ts @@ -53,6 +53,9 @@ interface RenderInput { pdfBytes: ArrayBuffer; pageNumber: number; scale?: number; + targetWidth?: number; + format?: 'png' | 'jpeg'; + jpegQuality?: number; } function createPdfjsCanvasFactory(runtime: CanvasRuntime) { @@ -81,10 +84,18 @@ function createPdfjsCanvasFactory(runtime: CanvasRuntime) { }; } -export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderInput): Promise<{ +export async function renderPage({ + pdfBytes, + pageNumber, + scale = 1.5, + targetWidth, + format = 'png', + jpegQuality = 82, +}: RenderInput): Promise<{ width: number; height: number; - png: Buffer; + image: Buffer; + contentType: 'image/png' | 'image/jpeg'; }> { // pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so // callers can safely reuse their original bytes across pages/calls. @@ -116,7 +127,11 @@ export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderIn const pdf = await loadingTask.promise; try { const page = await pdf.getPage(pageNumber); - const viewport = page.getViewport({ scale }); + const baseViewport = page.getViewport({ scale: 1.0 }); + const effectiveScale = typeof targetWidth === 'number' && Number.isFinite(targetWidth) && targetWidth > 0 + ? (Math.max(1, Math.round(targetWidth)) / Math.max(1, baseViewport.width)) + : scale; + const viewport = page.getViewport({ scale: effectiveScale }); const width = Math.max(1, Math.floor(viewport.width)); const height = Math.max(1, Math.floor(viewport.height)); const canvas = canvasRuntime.createCanvasFn(width, height); @@ -130,10 +145,15 @@ export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderIn intent: 'display', }); await renderTask.promise; + const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png'; + const image = format === 'jpeg' + ? canvas.toBuffer('image/jpeg', jpegQuality) + : canvas.toBuffer('image/png'); return { width, height, - png: canvas.toBuffer('image/png'), + image, + contentType, }; } finally { await pdf.destroy().catch(() => undefined); diff --git a/src/lib/server/pdf-layout/runLayoutModel.ts b/src/lib/server/pdf-layout/runLayoutModel.ts index 7407ea0..a03f21c 100644 --- a/src/lib/server/pdf-layout/runLayoutModel.ts +++ b/src/lib/server/pdf-layout/runLayoutModel.ts @@ -7,7 +7,7 @@ interface RunLayoutInput { pageWidth: number; pageHeight: number; textItems: PdfTextItem[]; - pagePng: Buffer; + pageImage: Buffer; } const DEFAULT_INPUT_SIZE = 800; @@ -255,9 +255,9 @@ function normalizeModelLabel(rawLabel: string): string { } export async function runLayoutModel(input: RunLayoutInput): Promise { - const { pageWidth, pageHeight, textItems, pagePng } = input; + const { pageWidth, pageHeight, textItems, pageImage } = input; if (!textItems.length) return []; - if (!pagePng || pagePng.byteLength === 0) { + if (!pageImage || pageImage.byteLength === 0) { throw new Error('layout-render-missing-page-image'); } @@ -269,8 +269,8 @@ export async function runLayoutModel(input: RunLayoutInput): Promise Date: Mon, 18 May 2026 12:25:41 -0600 Subject: [PATCH 004/137] feat(pdf): add block geometry-based highlighting using parsed document and locator Implement block-level highlighting in PDF viewer by leveraging parsed document structure and TTS segment locator. Extend highlightPattern API to accept options for parsedDocument, locator, and block geometry mode. Update PDFViewer to use block geometry highlighting when available, improving accuracy and alignment of sentence and word highlights with ONNX-based DocLayoutV3 parsing. Refactor highlighting logic to support both legacy span-based and new geometry-based approaches. --- src/app/(app)/pdf/[id]/usePdfDocument.ts | 11 +- src/components/views/PDFViewer.tsx | 19 ++ src/lib/client/pdf.ts | 223 +++++++++++++++++++++-- 3 files changed, 240 insertions(+), 13 deletions(-) diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index dc9adf2..774f46d 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -77,7 +77,16 @@ export interface PdfDocumentState { // PDF functionality onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void; - highlightPattern: (text: string, pattern: string, containerRef: RefObject) => void; + highlightPattern: ( + text: string, + pattern: string, + containerRef: RefObject, + options?: { + parsedDocument?: ParsedPdfDocument | null; + locator?: TTSSegmentLocator | null; + useBlockGeometryOnly?: boolean; + }, + ) => void; clearHighlights: () => void; clearWordHighlights: () => void; highlightWordIndex: ( diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index b11447e..483ec55 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -56,6 +56,7 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { currentSentence, currentWordIndex, currentSentenceAlignment, + currentSegment, skipToLocation, } = useTTS(); @@ -148,6 +149,12 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { const seq = ++sentenceHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current; lastSentenceLayoutKeyRef.current = layoutKey; + const activeLocator = currentSegment?.ownerLocator ?? null; + const hasParsedBlockLocator = + !!parsedDocument + && activeLocator?.readerType === 'pdf' + && typeof activeLocator.blockId === 'string' + && activeLocator.blockId.length > 0; if (isLayoutChange) { clearHighlights(); @@ -158,6 +165,15 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { const container = containerRef.current; if (!container) return; + if (hasParsedBlockLocator) { + highlightPattern(currDocText, currentSentence, containerRef as RefObject, { + parsedDocument, + locator: activeLocator, + useBlockGeometryOnly: !pdfWordHighlightEnabled, + }); + return; + } + const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); if (!spans.length) { if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75); @@ -176,9 +192,12 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { }, [ currDocText, currentSentence, + currentSegment, highlightPattern, clearHighlights, pdfHighlightEnabled, + pdfWordHighlightEnabled, + parsedDocument, layoutKey, clearSentenceHighlightTimeouts, scheduleSentenceTimeout diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index 8ecc635..5941e8d 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -3,9 +3,10 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; -import type { ParsedPdfPage, ParsedPdfBlockKind } from '@/types/parsed-pdf'; +import type { ParsedPdfDocument, ParsedPdfPage, ParsedPdfBlockKind } from '@/types/parsed-pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; import { CmpStr } from 'cmpstr'; +import type { TTSSegmentLocator } from '@/types/client'; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); @@ -320,14 +321,197 @@ export async function extractTextFromPDF( // Highlighting functions let highlightPatternSeq = 0; -export function clearHighlights() { - const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); - textNodes.forEach((node) => { - const element = node as HTMLElement; - element.style.backgroundColor = ''; - element.style.opacity = '1'; - }); +type HighlightPatternOptions = { + parsedDocument?: ParsedPdfDocument | null; + locator?: TTSSegmentLocator | null; + useBlockGeometryOnly?: boolean; +}; +function getHighlightLayerForPage(pageElement: HTMLElement): { + layer: HTMLElement; + pageRect: DOMRect; +} { + let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; + if (!layer) { + layer = document.createElement('div'); + layer.className = 'pdf-highlight-layer'; + pageElement.appendChild(layer); + } + + layer.style.position = 'absolute'; + layer.style.inset = '0'; + layer.style.pointerEvents = 'none'; + layer.style.zIndex = '4'; + layer.style.overflow = 'hidden'; + layer.style.transform = 'translateZ(0)'; + + return { layer, pageRect: pageElement.getBoundingClientRect() }; +} + +function findRenderedPageElement(container: HTMLElement, pageNumber: number): HTMLElement | null { + const direct = container.querySelector(`.react-pdf__Page[data-page-number="${pageNumber}"]`) as HTMLElement | null; + if (direct) return direct; + + const pageNodes = Array.from(container.querySelectorAll('.react-pdf__Page')) as HTMLElement[]; + for (const pageNode of pageNodes) { + const attr = pageNode.getAttribute('data-page-number'); + if (Number(attr) === pageNumber) return pageNode; + } + return null; +} + +function highlightParsedBlockGeometry( + containerRef: React.RefObject, + parsedDocument: ParsedPdfDocument, + locator: TTSSegmentLocator, +): boolean { + if (locator.readerType !== 'pdf') return false; + if (!locator.blockId) return false; + const container = containerRef.current; + if (!container) return false; + + let targetBlock: + | ParsedPdfPage['blocks'][number] + | null = null; + for (const page of parsedDocument.pages) { + const found = page.blocks.find((block) => block.id === locator.blockId); + if (found) { + targetBlock = found; + break; + } + } + if (!targetBlock) return false; + + let firstRect: { top: number; left: number } | null = null; + let drewAny = false; + + for (const fragment of targetBlock.fragments) { + const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); + if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; + + const pageElement = findRenderedPageElement(container, fragment.page); + if (!pageElement) continue; + + const { layer, pageRect } = getHighlightLayerForPage(pageElement); + const [x0, y0, x1, y1] = fragment.bbox; + const left = (x0 / parsedPage.width) * pageRect.width; + const top = (y0 / parsedPage.height) * pageRect.height; + const width = ((x1 - x0) / parsedPage.width) * pageRect.width; + const height = ((y1 - y0) / parsedPage.height) * pageRect.height; + + if (!(width > 0 && height > 0)) continue; + + const highlight = document.createElement('div'); + highlight.className = 'pdf-text-highlight-overlay'; + highlight.style.position = 'absolute'; + highlight.style.backgroundColor = 'grey'; + highlight.style.opacity = '0.4'; + highlight.style.pointerEvents = 'none'; + highlight.style.zIndex = '1'; + highlight.style.left = `${left}px`; + highlight.style.top = `${top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + layer.appendChild(highlight); + + if (!firstRect) { + firstRect = { top: pageRect.top + top, left: pageRect.left + left }; + } + drewAny = true; + } + + if (!drewAny || !firstRect) return drewAny; + + const containerRect = container.getBoundingClientRect(); + const visibleTop = container.scrollTop; + const visibleBottom = visibleTop + containerRect.height; + const elementTop = firstRect.top - containerRect.top + container.scrollTop; + + if (elementTop < visibleTop || elementTop > visibleBottom) { + container.scrollTo({ + top: elementTop - containerRect.height / 3, + behavior: 'smooth', + }); + } + return true; +} + +function resolveParsedBlock( + parsedDocument: ParsedPdfDocument | null | undefined, + locator: TTSSegmentLocator | null | undefined, +): ParsedPdfPage['blocks'][number] | null { + if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { + return null; + } + + for (const page of parsedDocument.pages) { + const found = page.blocks.find((block) => block.id === locator.blockId); + if (found) return found; + } + return null; +} + +function isRectOverlap( + a: { left: number; top: number; right: number; bottom: number }, + b: { left: number; top: number; right: number; bottom: number }, +): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function collectSpanNodesForParsedBlock( + container: HTMLElement, + parsedDocument: ParsedPdfDocument, + locator: TTSSegmentLocator, +): HTMLElement[] | null { + const block = resolveParsedBlock(parsedDocument, locator); + if (!block) return null; + + const collected: HTMLElement[] = []; + const seen = new Set(); + + for (const fragment of block.fragments) { + const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); + if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; + + const pageElement = findRenderedPageElement(container, fragment.page); + if (!pageElement) continue; + + const textLayer = pageElement.querySelector('.react-pdf__Page__textContent') as HTMLElement | null; + if (!textLayer) continue; + + const pageRect = pageElement.getBoundingClientRect(); + const [x0, y0, x1, y1] = fragment.bbox; + const blockRect = { + left: (x0 / parsedPage.width) * pageRect.width, + top: (y0 / parsedPage.height) * pageRect.height, + right: (x1 / parsedPage.width) * pageRect.width, + bottom: (y1 / parsedPage.height) * pageRect.height, + }; + + const spans = Array.from(textLayer.querySelectorAll('span')) as HTMLElement[]; + for (const span of spans) { + const node = span.firstChild; + if (!node || node.nodeType !== Node.TEXT_NODE) continue; + + const rect = span.getBoundingClientRect(); + const spanRect = { + left: rect.left - pageRect.left, + top: rect.top - pageRect.top, + right: rect.right - pageRect.left, + bottom: rect.bottom - pageRect.top, + }; + + if (!isRectOverlap(spanRect, blockRect)) continue; + if (seen.has(span)) continue; + seen.add(span); + collected.push(span); + } + } + + return collected.length > 0 ? collected : null; +} + +export function clearHighlights() { const overlays = document.querySelectorAll('.pdf-text-highlight-overlay'); overlays.forEach((node) => { const element = node as HTMLElement; @@ -357,7 +541,8 @@ export function clearWordHighlights() { export function highlightPattern( text: string, pattern: string, - containerRef: React.RefObject + containerRef: React.RefObject, + options?: HighlightPatternOptions, ) { const seq = ++highlightPatternSeq; clearHighlights(); @@ -371,10 +556,24 @@ export function highlightPattern( lastSentencePattern = cleanPattern; lastSentenceWordToTokenMap = null; lastSentenceTokenWindow = null; + const parsedDocument = options?.parsedDocument ?? null; + const locator = options?.locator ?? null; - const spanNodes = Array.from( - container.querySelectorAll('.react-pdf__Page__textContent span') - ) as HTMLElement[]; + if ( + parsedDocument + && locator + && options?.useBlockGeometryOnly + && highlightParsedBlockGeometry(containerRef, parsedDocument, locator) + ) { + return; + } + + const spanNodes = ( + parsedDocument && locator + ? (collectSpanNodesForParsedBlock(container, parsedDocument, locator) + ?? Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[]) + : Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[] + ); if (!spanNodes.length) return; lastSpanNodes = spanNodes; From 90e7caac43ef0b45d2d6a83cff687dfe37706995 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 18 May 2026 12:37:26 -0600 Subject: [PATCH 005/137] refactor(previews): streamline preview generation and update image variant to 400px Remove temporary working directory usage and in-place file writes from preview generation, simplifying logic for both PDF and EPUB previews. Change default preview image variant from 480px to 400px width, updating related constants and cache schema version to maintain consistency across client and server code. --- src/lib/client/cache/previews.ts | 2 +- .../server/documents/previews-blobstore.ts | 6 +-- src/lib/server/documents/previews.ts | 37 +++++-------------- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts index d8ae889..736c273 100644 --- a/src/lib/client/cache/previews.ts +++ b/src/lib/client/cache/previews.ts @@ -8,7 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); -const PREVIEW_CACHE_SCHEMA_VERSION = 3; +const PREVIEW_CACHE_SCHEMA_VERSION = 4; function revokeIfBlobUrl(url: string | null | undefined): void { if (!url) return; diff --git a/src/lib/server/documents/previews-blobstore.ts b/src/lib/server/documents/previews-blobstore.ts index 01d9ecd..aa2d973 100644 --- a/src/lib/server/documents/previews-blobstore.ts +++ b/src/lib/server/documents/previews-blobstore.ts @@ -10,10 +10,10 @@ import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const DEFAULT_NAMESPACE_SEGMENT = '_default'; -export const DOCUMENT_PREVIEW_VARIANT = 'card-480-jpeg'; -export const DOCUMENT_PREVIEW_FILE_NAME = 'card-480.jpg'; +export const DOCUMENT_PREVIEW_VARIANT = 'card-400-jpeg'; +export const DOCUMENT_PREVIEW_FILE_NAME = 'card-400.jpg'; export const DOCUMENT_PREVIEW_CONTENT_TYPE = 'image/jpeg'; -export const DOCUMENT_PREVIEW_WIDTH = 480; +export const DOCUMENT_PREVIEW_WIDTH = 400; function sanitizeNamespace(namespace: string | null): string | null { if (!namespace) return null; diff --git a/src/lib/server/documents/previews.ts b/src/lib/server/documents/previews.ts index 8f4414c..9e7000a 100644 --- a/src/lib/server/documents/previews.ts +++ b/src/lib/server/documents/previews.ts @@ -1,7 +1,4 @@ import { randomUUID } from 'crypto'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; import { and, eq, inArray, lt, or, sql } from 'drizzle-orm'; import { db } from '@/db'; import { documentPreviews } from '@/db/schema'; @@ -290,31 +287,17 @@ async function markPreviewFailed(docId: string, namespaceKey: string, error: unk } async function generateAndStorePreview(doc: PreviewSourceDocument, namespace: string | null): Promise { - let workDir: string | null = null; - try { - const sourceBytes = await getDocumentBlob(doc.id, namespace); - workDir = await mkdtemp(join(tmpdir(), 'openreader-preview-')); - const sourcePath = join(workDir, 'source'); - await writeFile(sourcePath, sourceBytes); - - let rendered; - if (doc.type === 'pdf') { - rendered = await renderPdfFirstPageToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); - } else if (doc.type === 'epub') { - rendered = await renderEpubCoverToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); - } else { - throw new Error(`Unsupported preview type: ${doc.type}`); - } - - // Replace-in-place semantics: clear old preview objects for this document - // prefix before writing the current variant so stale files don't linger. - await deleteDocumentPreviewArtifacts(doc.id, namespace).catch(() => undefined); - await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); - } finally { - if (workDir) { - await rm(workDir, { recursive: true, force: true }).catch(() => {}); - } + const sourceBytes = await getDocumentBlob(doc.id, namespace); + let rendered; + if (doc.type === 'pdf') { + rendered = await renderPdfFirstPageToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else if (doc.type === 'epub') { + rendered = await renderEpubCoverToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else { + throw new Error(`Unsupported preview type: ${doc.type}`); } + // Hot path: overwrite current variant key directly to avoid prefix list/delete latency. + await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); } function pendingResult(status: PreviewStatus, lastError: string | null): EnsureDocumentPreviewResult { From 6ab5c230c225cd30b7d13eda7de9966b1db30b9a Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 18 May 2026 13:21:43 -0600 Subject: [PATCH 006/137] feat(pdf): improve cross-page block stitching and enforce TTS segment source boundaries Enhance PDF cross-page block stitching to move only sentence continuations and preserve remaining text on the following page. Update TTS segment planning to support strict source boundary enforcement, ensuring segments do not cross block or paragraph-title boundaries. Add new tests for both features and refactor PDF text item normalization to filter out skewed or rotated runs. --- src/components/documents/DocumentSettings.tsx | 55 ++++---- src/components/formPrimitives.tsx | 133 ++++++++++++++++-- src/components/icons/Icons.tsx | 16 +++ src/components/views/PDFViewer.tsx | 31 ++++ src/lib/server/pdf-layout/parsePdf.ts | 18 ++- .../pdf-layout/stitchCrossPageBlocks.ts | 57 +++++++- src/lib/shared/tts-segment-plan.ts | 107 ++++++++++++-- .../pdf-parse-normalize-text-items.spec.ts | 40 ++++++ .../unit/pdf-stitch-cross-page-blocks.spec.ts | 21 +++ tests/unit/tts-segment-plan.spec.ts | 57 ++++++++ 10 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 tests/unit/pdf-parse-normalize-text-items.spec.ts diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index bdb534c..ed6aa92 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -16,8 +16,8 @@ import { clampTtsSegmentMaxBlockLength, } from '@/types/config'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; -import { Section, ToggleRow, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; -import { RefreshIcon } from '@/components/icons/Icons'; +import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; +import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [ @@ -422,19 +422,27 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { title="PDF Structure" subtitle="Layout-aware parsing controls." variant="flat" + action={ +
+ + + PP-DocLayout-V3 + + + {pdf.parseStatus ?? 'pending'} + + +
+ } > -
- Parse status: {pdf.parseStatus ?? 'pending'} - -
-
-

Skip while reading aloud

- {PDF_SKIP_KIND_OPTIONS.map((option) => { - const checked = pdf.skipBlockKinds.includes(option.kind); - return ( - +

Skip while reading aloud

+
+ {PDF_SKIP_KIND_OPTIONS.map((option) => ( + pdf.onToggleSkipKind(option.kind, enabled)} - variant="flat" /> - ); - })} + ))} +
)} diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx index 73ce83d..6095591 100644 --- a/src/components/formPrimitives.tsx +++ b/src/components/formPrimitives.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useId, type ReactNode } from 'react'; /** * Shared compact form primitives used by settings-like surfaces across @@ -78,7 +78,7 @@ export function Section({ variant = 'panel', }: { title: string; - subtitle?: string; + subtitle?: ReactNode; children: ReactNode; action?: ReactNode; variant?: 'panel' | 'flat'; @@ -130,6 +130,65 @@ export function Card({ ); } +export type SwitchSize = 'sm' | 'md'; + +const SWITCH_SIZE: Record = { + sm: { + track: 'h-4 w-7', + thumb: 'h-3 w-3', + on: 'translate-x-3', + off: 'translate-x-0.5', + }, + md: { + track: 'h-5 w-9', + thumb: 'h-4 w-4', + on: 'translate-x-4', + off: 'translate-x-0.5', + }, +}; + +export function Switch({ + checked, + onChange, + disabled = false, + size = 'md', + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + size?: SwitchSize; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; +}) { + const s = SWITCH_SIZE[size]; + return ( + + ); +} + export function ToggleRow({ label, description, @@ -147,32 +206,76 @@ export function ToggleRow({ right?: ReactNode; variant?: 'card' | 'flat'; }) { + const labelId = useId(); + const descId = useId(); const rowClass = variant === 'flat' ? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]' : 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]'; + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; return (
- +
+ {label} + {description} +
{right ?
{right}
: null} +
); } +export function CheckItem({ + label, + checked, + onChange, + disabled = false, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + const labelId = useId(); + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; + return ( +
+ + {label} + + +
+ ); +} + export function Field({ label, hint, diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index b23217d..9e0d10b 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -620,3 +620,19 @@ export function FileSettingsIcon(props: React.SVGProps) { ); } + +export function SparkleIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 483ec55..48c961b 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -360,6 +360,37 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { } for (const list of map.values()) { + const geoKey = (entry: { + block: ParsedPdfBlock; + fragment: ParsedPdfBlock['fragments'][number]; + isContinuation: boolean; + }): string => { + const [x0, y0, x1, y1] = entry.fragment.bbox; + const round = (value: number) => Math.round(value * 10) / 10; + return [ + entry.block.kind, + round(x0), + round(y0), + round(x1), + round(y1), + ].join(':'); + }; + + const continuationGeometry = new Set(); + for (const entry of list) { + if (entry.isContinuation) { + continuationGeometry.add(geoKey(entry)); + } + } + + const filtered = list.filter((entry) => { + if (entry.isContinuation) return true; + return !continuationGeometry.has(geoKey(entry)); + }); + + list.length = 0; + list.push(...filtered); + list.sort((a, b) => { if (a.fragment.readingOrder !== b.fragment.readingOrder) { return a.fragment.readingOrder - b.fragment.readingOrder; diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts index 6939a0a..cbb8f29 100644 --- a/src/lib/server/pdf-layout/parsePdf.ts +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -15,9 +15,21 @@ interface ParsePdfInput { const LAYOUT_RENDER_SCALE = 1.5; -function normalizeTextItems(items: TextItem[], pageHeight: number): PdfTextItem[] { +export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] { return items - .filter((item) => typeof item.str === 'string' && item.str.trim().length > 0) + .filter((item) => { + if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false; + const transform = item.transform; + if (!Array.isArray(transform) || transform.length < 6) return false; + + // Reject heavily skewed/rotated text runs (e.g. vertical margin labels + // such as arXiv metadata) so they do not get merged into body blocks. + const skewX = Number(transform[1] ?? 0); + const skewY = Number(transform[2] ?? 0); + if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; + + return true; + }) .map((item) => { const x = Number(item.transform[4] ?? 0); const width = Math.max(0, Number(item.width ?? 0)); @@ -71,7 +83,7 @@ export async function parsePdf(input: ParsePdfInput): Promise const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.0 }); const textContent = await page.getTextContent(); - const textItems = normalizeTextItems( + const textItems = normalizeTextItemsForLayout( textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), viewport.height, ); diff --git a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts index 2cafdde..0508769 100644 --- a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts +++ b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts @@ -28,6 +28,39 @@ function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { return true; } +function splitHeadContinuation(text: string): { continuation: string; remainder: string } { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return { continuation: '', remainder: '' }; + + const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']); + const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?'; + + for (let i = 0; i < normalized.length; i += 1) { + const ch = normalized[i]; + if (!isTerminal(ch)) continue; + + const prev = i > 0 ? normalized[i - 1] : ''; + const next = i + 1 < normalized.length ? normalized[i + 1] : ''; + if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue; + + let cut = i + 1; + while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1; + + const after = cut < normalized.length ? normalized[cut] : ''; + if (!after || /\s/.test(after) || /[A-Z]/.test(after)) { + return { + continuation: normalized.slice(0, cut).trim(), + remainder: normalized.slice(cut).trim(), + }; + } + } + + return { + continuation: normalized, + remainder: '', + }; +} + const HARD_BOUNDARY_KINDS = new Set([ 'paragraph_title', 'doc_title', @@ -81,9 +114,27 @@ export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument if (!tail || !head) continue; if (!canStitch(tail, head)) continue; - tail.fragments.push(...head.fragments); - tail.text = `${tail.text} ${head.text}`.replace(/\s+/g, ' ').trim(); - next.blocks.splice(headIndex, 1); + const { continuation, remainder } = splitHeadContinuation(head.text); + if (!continuation) continue; + + const continuationFragment = head.fragments[0] + ? { ...head.fragments[0], text: continuation } + : null; + + if (continuationFragment) { + tail.fragments.push(continuationFragment); + } + tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim(); + + if (!remainder) { + next.blocks.splice(headIndex, 1); + continue; + } + + head.text = remainder; + if (head.fragments[0]) { + head.fragments[0].text = remainder; + } } return { diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index de3fd0c..b33ca15 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -188,6 +188,7 @@ export function planCanonicalTtsSegments( const readerType = options.readerType ?? 'pdf'; const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries); const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; + const sourceSeparator = enforceSourceBoundaries ? '\n\n' : ' '; const preparedSources: PreparedSourceUnit[] = []; const textParts: string[] = []; let combinedLength = 0; @@ -197,8 +198,8 @@ export function planCanonicalTtsSegments( if (!text) continue; if (textParts.length > 0) { - textParts.push(' '); - combinedLength += 1; + textParts.push(sourceSeparator); + combinedLength += sourceSeparator.length; } const startOffset = combinedLength; @@ -215,9 +216,73 @@ export function planCanonicalTtsSegments( const canonicalText = textParts.join(''); const splitOptions = { maxBlockLength: options.maxBlockLength }; - const blocks = readerType === 'epub' - ? splitTextToTtsBlocksEPUB(canonicalText, splitOptions) - : splitTextToTtsBlocks(canonicalText, splitOptions); + const splitIntoBlocks = (text: string): string[] => + readerType === 'epub' + ? splitTextToTtsBlocksEPUB(text, splitOptions) + : splitTextToTtsBlocks(text, splitOptions); + + if (enforceSourceBoundaries) { + const segments: CanonicalTtsSegment[] = []; + + for (const source of preparedSources) { + const localBlocks = splitIntoBlocks(source.text); + let localRawCursor = 0; + let localNormalizedCursor = 0; + + for (const block of localBlocks) { + const text = block.trim(); + if (!text) continue; + + const exactStart = source.text.indexOf(text, localRawCursor); + let localStart: number; + let localEnd: number; + + if (exactStart >= 0) { + localStart = exactStart; + localEnd = exactStart + text.length; + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } else { + const flexible = findFlexibleOffset(source.text, text, localNormalizedCursor); + if (flexible) { + localStart = flexible.start; + localEnd = flexible.end; + localRawCursor = localEnd; + localNormalizedCursor = flexible.normalizedEnd; + } else { + // Never drop blocks in enforced boundary mode. + localStart = Math.max(0, Math.min(localRawCursor, source.text.length)); + localEnd = Math.max(localStart, Math.min(source.text.length, localStart + text.length)); + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } + } + + const absoluteStart = source.startOffset + localStart; + const absoluteEnd = source.startOffset + localEnd; + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: anchorForOffset(source, absoluteStart), + endAnchor: anchorForOffset(source, absoluteEnd), + spansSourceBoundary: false, + }); + } + } + + return { + version: TTS_SEGMENT_PLAN_VERSION, + readerType, + text: canonicalText, + segments, + }; + } + + const blocks = splitIntoBlocks(canonicalText); let rawCursor = 0; let normalizedCursor = 0; @@ -238,11 +303,33 @@ export function planCanonicalTtsSegments( normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, endOffset)).text.length; } else { const flexible = findFlexibleOffset(canonicalText, text, normalizedCursor); - if (!flexible) continue; - startOffset = flexible.start; - endOffset = flexible.end; - rawCursor = endOffset; - normalizedCursor = flexible.normalizedEnd; + if (!flexible) { + if (!enforceSourceBoundaries) continue; + + // In enforced-boundary mode (PDF block source units), never drop a + // split block just because canonical rematching failed. Prefer a + // best-effort anchor inside the source that the cursor currently sits + // in, then emit the segment text as-is. + const fallbackSource = findSourceForOffset(preparedSources, rawCursor, 'start') + ?? preparedSources[preparedSources.length - 1] + ?? null; + if (!fallbackSource) continue; + + const fallbackStart = Math.max(fallbackSource.startOffset, Math.min(rawCursor, fallbackSource.endOffset)); + const fallbackEnd = Math.max( + fallbackStart, + Math.min(fallbackSource.endOffset, fallbackStart + text.length), + ); + startOffset = fallbackStart; + endOffset = fallbackEnd; + rawCursor = fallbackEnd; + normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, fallbackEnd)).text.length; + } else { + startOffset = flexible.start; + endOffset = flexible.end; + rawCursor = endOffset; + normalizedCursor = flexible.normalizedEnd; + } } const ownerSource = findSourceForOffset(preparedSources, startOffset, 'start'); diff --git a/tests/unit/pdf-parse-normalize-text-items.spec.ts b/tests/unit/pdf-parse-normalize-text-items.spec.ts new file mode 100644 index 0000000..d763a12 --- /dev/null +++ b/tests/unit/pdf-parse-normalize-text-items.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +import { normalizeTextItemsForLayout } from '../../src/lib/server/pdf-layout/parsePdf'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; + +function makeTextItem( + str: string, + transform: [number, number, number, number, number, number], + width = 100, +): TextItem { + return { + str, + transform, + width, + height: Math.abs(transform[3]), + dir: 'ltr', + fontName: 'test', + hasEOL: false, + } as unknown as TextItem; +} + +test.describe('normalizeTextItemsForLayout', () => { + test('keeps horizontal body text and drops rotated/skewed margin text', () => { + const horizontal = makeTextItem( + 'Powered by large language models', + [10, 0, 0, 10, 100, 600], + ); + + // Typical 90deg-ish rotated/skewed run (like side metadata labels). + const rotated = makeTextItem( + 'arXiv:2407.16741v3 [cs.SE] 18 Apr 2025', + [0, 10, -10, 0, 30, 400], + ); + + const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800); + expect(normalized).toHaveLength(1); + expect(normalized[0]?.text).toBe('Powered by large language models'); + }); +}); + diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts index cdc3ac2..34902a9 100644 --- a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -57,6 +57,27 @@ test.describe('stitchCrossPageBlocks', () => { expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); }); + test('moves only the continuation sentence and keeps remaining text on next page', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'text', 'into the next page. This should stay on page two.', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks).toHaveLength(1); + expect(page2?.blocks[0]?.id).toBe('b2'); + expect(page2?.blocks[0]?.text).toBe('This should stay on page two.'); + }); + test('does not stitch across paragraph-title boundary', () => { const doc = makeDoc( [ diff --git a/tests/unit/tts-segment-plan.spec.ts b/tests/unit/tts-segment-plan.spec.ts index 4efbd21..8f03797 100644 --- a/tests/unit/tts-segment-plan.spec.ts +++ b/tests/unit/tts-segment-plan.spec.ts @@ -164,6 +164,63 @@ test.describe('planCanonicalTtsSegments', () => { expect(plan.segments).toHaveLength(1); expect(plan.segments[0].ownerSourceKey).toBe('page:1'); }); + + test('keeps paragraph-title boundaries when source boundaries are enforced', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'abstract', + locator: { page: 1, readerType: 'pdf', blockId: 'a1' }, + text: 'Released under the permissive MIT license, OpenHands is a community project spanning academia and industry with more than 2.1K contributions.', + }, + { + sourceKey: 'intro-title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro-body', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + text: 'Powered by large language models (LLMs; OpenAI 2024b; Team et al. 2023), user-facing AI systems have become increasingly capable of performing complex tasks such as accurately responding to user queries, solving math problems, and generating code.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-title' && segment.text === '1 INTRODUCTION')).toBeTruthy(); + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-body' && segment.text.startsWith('Powered by large language models'))).toBeTruthy(); + expect(plan.segments.some((segment) => segment.text.startsWith('1 INTRODUCTION Powered by'))).toBeFalsy(); + }); + + test('does not drop first sentence when canonical rematch fails in enforced boundary mode', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + // Missing whitespace after sentence terminal is a common PDF extraction artifact. + text: 'Powered by large language models have become increasingly capable of generating code.In particular, AI agents have recently received ever-increasing research focus.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + const introSegments = plan.segments + .filter((segment) => segment.ownerSourceKey === 'intro') + .map((segment) => segment.text); + const combinedIntro = introSegments.join(' '); + expect(combinedIntro.includes('Powered by large language models')).toBeTruthy(); + expect(combinedIntro.includes('In particular, AI agents')).toBeTruthy(); + }); }); test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => { From 874e5ef3592e89a5cea75fe40e129a9d5c2d89fd Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 13:00:21 -0600 Subject: [PATCH 007/137] refactor(whisper): migrate word alignment to ONNX backend and remove whisper.cpp integration Replace the previous whisper.cpp-based word alignment with a fully ONNX-based implementation using onnxruntime-node and @huggingface/tokenizers. Add new Whisper ONNX model management, alignment mapping, and spectral analysis modules. Remove all code and documentation referencing whisper.cpp, update environment variables, Dockerfile, and docs to reflect ONNX-only alignment. Add unit tests for alignment and ONNX model logic. --- .env.example | 21 +- Dockerfile | 26 +- README.md | 2 +- docs-site/docs/about/acknowledgements.md | 2 +- docs-site/docs/deploy/local-development.md | 45 +- docs-site/docs/deploy/vercel-deployment.md | 9 +- docs-site/docs/introduction.md | 2 +- .../docs/reference/environment-variables.md | 31 +- docs-site/docs/reference/stack.md | 2 +- next.config.ts | 11 +- package.json | 1 + pnpm-lock.yaml | 8 + scripts/openreader-entrypoint.mjs | 15 +- src/app/api/whisper/route.ts | 47 - src/lib/client/api/audiobooks.ts | 19 - src/lib/server/compute/index.ts | 17 +- src/lib/server/compute/local.ts | 6 +- src/lib/server/compute/mode.ts | 16 + src/lib/server/runtime-config.ts | 4 +- src/lib/server/whisper/alignment-mapping.ts | 46 + src/lib/server/whisper/alignment.ts | 1257 ++++++++++++----- src/lib/server/whisper/ensureModel.ts | 226 +++ src/lib/server/whisper/model/LICENSE.txt | 21 + src/lib/server/whisper/model/manifest.json | 76 + src/lib/server/whisper/model/mel_filters.npz | Bin 0 -> 4271 bytes src/lib/server/whisper/spectral.ts | 21 + src/lib/server/whisper/token-timestamps.ts | 449 ++++++ src/types/client.ts | 14 +- tests/unit/whisper-alignment-mapping.spec.ts | 21 + tests/unit/whisper-alignment-smoke.spec.ts | 37 + tests/unit/whisper-ensure-model.spec.ts | 71 + tests/unit/whisper-spectral.spec.ts | 34 + tests/unit/whisper-token-timestamps.spec.ts | 85 ++ 33 files changed, 2131 insertions(+), 511 deletions(-) delete mode 100644 src/app/api/whisper/route.ts create mode 100644 src/lib/server/compute/mode.ts create mode 100644 src/lib/server/whisper/alignment-mapping.ts create mode 100644 src/lib/server/whisper/ensureModel.ts create mode 100644 src/lib/server/whisper/model/LICENSE.txt create mode 100644 src/lib/server/whisper/model/manifest.json create mode 100644 src/lib/server/whisper/model/mel_filters.npz create mode 100644 src/lib/server/whisper/spectral.ts create mode 100644 src/lib/server/whisper/token-timestamps.ts create mode 100644 tests/unit/whisper-alignment-mapping.spec.ts create mode 100644 tests/unit/whisper-alignment-smoke.spec.ts create mode 100644 tests/unit/whisper-ensure-model.spec.ts create mode 100644 tests/unit/whisper-spectral.spec.ts create mode 100644 tests/unit/whisper-token-timestamps.spec.ts diff --git a/.env.example b/.env.example index ec650c1..03d69a5 100644 --- a/.env.example +++ b/.env.example @@ -77,10 +77,7 @@ RUN_FS_MIGRATIONS= IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= -# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation -WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli - -# Heavy compute backend mode for whisper alignment + PDF layout parsing. +# Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. # local = run compute in-process (default) # none = disable both capabilities (good for preview/serverless) # worker = reserved for future external worker mode (not implemented in v1) @@ -88,6 +85,22 @@ OPENREADER_COMPUTE_MODE=local # OPENREADER_COMPUTE_WORKER_URL= # OPENREADER_COMPUTE_WORKER_TOKEN= +# Optional overrides for Whisper ONNX artifacts +# Defaults target: onnx-community/whisper-base_timestamped int8 +# OPENREADER_WHISPER_MODEL_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/config.json +# OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/generation_config.json +# OPENREADER_WHISPER_MODEL_TOKENIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer.json +# OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer_config.json +# OPENREADER_WHISPER_MODEL_MERGES_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/merges.txt +# OPENREADER_WHISPER_MODEL_VOCAB_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/vocab.json +# OPENREADER_WHISPER_MODEL_NORMALIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/normalizer.json +# OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/added_tokens.json +# OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/preprocessor_config.json +# OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/special_tokens_map.json +# OPENREADER_WHISPER_MODEL_ENCODER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/encoder_model_int8.onnx +# OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_model_merged_int8.onnx +# OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_with_past_model_int8.onnx + # Optional overrides for PDF layout model artifacts # OPENREADER_PDF_LAYOUT_MODEL_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx # OPENREADER_PDF_LAYOUT_MODEL_DATA_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data diff --git a/Dockerfile b/Dockerfile index 764b387..0dcfe74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,4 @@ -# Stage 1: build whisper.cpp (no model download – the app handles that) -FROM alpine:3.23 AS whisper-builder - -RUN apk add --no-cache git cmake build-base - -WORKDIR /opt - -ARG TARGETARCH - -RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \ - cd whisper.cpp && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \ - cmake --build build -j -RUN wget -qO /tmp/whisper.cpp-LICENSE.txt "https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/LICENSE" - -# Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini) +# Stage 1: extract seaweedfs weed binary (for optional embedded weed mini) # Pin to 4.18 because CI observed upload regressions on 4.19. FROM chrislusf/seaweedfs:4.18 AS seaweedfs-builder RUN cp "$(command -v weed)" /tmp/weed && \ @@ -75,17 +60,12 @@ COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LIC # Include static model notices for runtime-downloaded assets. COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt -# Copy the compiled whisper.cpp build output into the runtime image -# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) -COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build -COPY --from=whisper-builder /tmp/whisper.cpp-LICENSE.txt /licenses/whisper.cpp-LICENSE.txt # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed -# Point the app at the compiled whisper-cli binary and ensure its libs are discoverable -ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli -ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build +# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts. +COPY src/lib/server/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt # Expose the port the app runs on EXPOSE 3003 diff --git a/README.md b/README.md index 210e215..63a99ec 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`). +- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment in local compute mode (`OPENREADER_COMPUTE_MODE=local`). - 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. diff --git a/docs-site/docs/about/acknowledgements.md b/docs-site/docs/about/acknowledgements.md index 0a6c5ec..b47cccb 100644 --- a/docs-site/docs/about/acknowledgements.md +++ b/docs-site/docs/about/acknowledgements.md @@ -10,7 +10,7 @@ This project is built with support from the following open-source projects and t - [SQLite](https://www.sqlite.org/) - [PostgreSQL](https://www.postgresql.org/) - [SeaweedFS](https://github.com/seaweedfs/seaweedfs) -- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) +- [OpenAI Whisper](https://github.com/openai/whisper) - [ffmpeg](https://ffmpeg.org) - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [react-reader](https://github.com/happyr/react-reader) diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 875c83f..1bc2183 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -114,50 +114,13 @@ sudo apt install -y libreoffice
-whisper.cpp (optional, for word-by-word highlighting) +Word-by-word highlighting (optional) -Install build dependencies: +No extra native Whisper CLI build step is required. - - +Set `OPENREADER_COMPUTE_MODE=local` to enable built-in ONNX word alignment in-process. -```bash -brew install cmake -``` - - - - -```bash -# Debian/Ubuntu example -sudo apt update -sudo apt install -y git build-essential cmake -``` - - - - -Build whisper.cpp: - -```bash -# clone and build whisper.cpp (no model download needed – OpenReader handles that) -git clone https://github.com/ggml-org/whisper.cpp.git -cd whisper.cpp -cmake -B build -cmake --build build -j --config Release - -# point OpenReader to the compiled whisper-cli binary -echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli" -``` - -If you are not on Debian/Ubuntu, install equivalent packages with your distro package manager: - -- Fedora/RHEL: use `dnf` (`gcc gcc-c++ make cmake curl git tar xz`) -- Arch: use `pacman` (`base-devel cmake curl git tar xz`) - -:::tip -Set `OPENREADER_COMPUTE_MODE=local` and `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. -::: +If you need mirrors or pinned artifact locations, set `OPENREADER_WHISPER_MODEL_*_URL` overrides in `.env`.
diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 1f6f2e0..10fe73a 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -37,7 +37,7 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in # Heavy compute (recommended on Vercel in v1) # local = requires native binaries/models in-process -# none = disable whisper alignment + PDF layout parsing +# none = disable ONNX whisper alignment + PDF layout parsing OPENREADER_COMPUTE_MODE=none # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) @@ -95,8 +95,7 @@ Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic - `/api/audiobook` - `/api/audiobook/chapter` -- `/api/audiobook/status` -- `/api/whisper` +- `/api/tts/segments/ensure` :::info `serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output. @@ -113,7 +112,7 @@ FFmpeg workloads benefit from more memory/CPU. This repo includes: "$schema": "https://openapi.vercel.sh/vercel.json", "functions": { "app/api/audiobook/route.ts": { "memory": 3009 }, - "app/api/whisper/route.ts": { "memory": 3009 } + "app/api/tts/segments/ensure/route.ts": { "memory": 3009 } } } ``` @@ -130,4 +129,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If you later enable compute locally (`OPENREADER_COMPUTE_MODE=local`), verify word highlighting timestamps on a TTS run. +4. If you run with local compute (`OPENREADER_COMPUTE_MODE=local`) outside Vercel, verify word highlighting timestamps on a TTS run. diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index c4d09e0..53264c6 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -22,7 +22,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models - [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts` - 📖 **Read Along Experience** - - Real-time highlighting for PDF/EPUB, with optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps + - Real-time highlighting for PDF/EPUB, with built-in ONNX Whisper word-level timestamps in local compute mode - 🛜 **Document Storage** - Documents are persisted in server blob/object storage for consistent access - ⚡ **Segment-based TTS Playback** for reusable generation + preloading diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index f9d43cc..9c58793 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,14 +53,14 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing | +| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing | | `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | | `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | | `OPENREADER_PDF_LAYOUT_MODEL_URL` | PDF layout model | PP-DocLayoutV3 ONNX URL | Override ONNX model URL for `ensureModel()` | | `OPENREADER_PDF_LAYOUT_MODEL_DATA_URL` | PDF layout model | PP-DocLayoutV3 ONNX data URL | Override ONNX external data URL for `ensureModel()` | | `OPENREADER_PDF_LAYOUT_CONFIG_URL` | PDF layout model | PP-DocLayoutV3 config URL | Override model config URL for `ensureModel()` | | `OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL` | PDF layout model | PP-DocLayoutV3 preprocessor URL | Override model preprocessor URL for `ensureModel()` | -| `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` | +| `OPENREADER_WHISPER_MODEL_*_URL` | Whisper ONNX model | onnx-community defaults | Optional per-artifact URL overrides for ONNX whisper-base_timestamped int8 downloads | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -355,7 +355,7 @@ Multiple library roots for server library import. ### OPENREADER_COMPUTE_MODE -Selects the backend for heavy compute features (word alignment + PDF layout parsing). +Selects the backend for heavy compute features (ONNX word alignment + PDF layout parsing). - Default: `local` - Supported in v1: @@ -403,12 +403,29 @@ Override URL for the PP-DocLayoutV3 `preprocessor_config.json` downloaded by `en - Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json` - You can pre-populate the model cache via `pnpm fetch-models` -### WHISPER_CPP_BIN +### OPENREADER_WHISPER_MODEL_*_URL -Absolute path to compiled `whisper.cpp` binary for word-level timestamps. +Optional per-artifact override URLs for the built-in ONNX Whisper alignment model downloader. -- Example: `/whisper.cpp/build/bin/whisper-cli` -- Required only for optional word-by-word highlighting +- Default base: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` +- Default model variant: int8 (`encoder_model_int8.onnx`, `decoder_model_merged_int8.onnx`, `decoder_with_past_model_int8.onnx`) +- Use these when you need mirrors, pinned snapshots, or air-gapped fetch routing. + +Supported override vars: + +- `OPENREADER_WHISPER_MODEL_CONFIG_URL` +- `OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL` +- `OPENREADER_WHISPER_MODEL_TOKENIZER_URL` +- `OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL` +- `OPENREADER_WHISPER_MODEL_MERGES_URL` +- `OPENREADER_WHISPER_MODEL_VOCAB_URL` +- `OPENREADER_WHISPER_MODEL_NORMALIZER_URL` +- `OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL` +- `OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL` +- `OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL` +- `OPENREADER_WHISPER_MODEL_ENCODER_URL` +- `OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL` +- `OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL` ### FFMPEG_BIN diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index cc45fa2..b6dc596 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -34,7 +34,7 @@ title: Stack - App tables are manually maintained in Drizzle schema files - Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 -- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps +- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, built-in ONNX Whisper (`onnx-community/whisper-base_timestamped` int8) for word timestamps ## Tooling and testing diff --git a/next.config.ts b/next.config.ts index 5179aeb..b35c2d0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,6 @@ const securityHeaders = [ value: 'max-age=63072000; includeSubDomains; preload', }, ]; - const nextConfig: NextConfig = { async headers() { return [ @@ -30,7 +29,13 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"], + serverExternalPackages: [ + "@napi-rs/canvas", + "ffmpeg-static", + "better-sqlite3", + "onnxruntime-node", + "@huggingface/tokenizers", + ], outputFileTracingIncludes: { '/api/audiobook': [ './node_modules/ffmpeg-static/ffmpeg', @@ -38,7 +43,7 @@ const nextConfig: NextConfig = { '/api/audiobook/chapter': [ './node_modules/ffmpeg-static/ffmpeg', ], - '/api/whisper': [ + '/api/tts/segments/ensure': [ './node_modules/ffmpeg-static/ffmpeg', ], '/api/documents/blob/preview/ensure': [ diff --git a/package.json b/package.json index d7fec12..63197cf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.1045.0", "@headlessui/react": "^2.2.10", + "@huggingface/tokenizers": "^0.1.3", "@napi-rs/canvas": "^0.1.100", "@tanstack/react-query": "^5.100.10", "@types/archiver": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c96e8fc..a2c265a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@headlessui/react': specifier: ^2.2.10 version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@huggingface/tokenizers': + specifier: ^0.1.3 + version: 0.1.3 '@napi-rs/canvas': specifier: ^0.1.100 version: 0.1.100 @@ -973,6 +976,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -5210,6 +5216,8 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) + '@huggingface/tokenizers@0.1.3': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 4909709..214201b 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -181,19 +181,20 @@ function spawnMainCommand(command, env) { const exitPromise = new Promise((resolve) => { child.on('error', (error) => { console.error('Failed to launch command:', error); - resolve(1); + resolve({ code: 1, signal: null, launchError: true }); }); child.on('exit', (code, signal) => { + console.error(`Main command exit event: code=${code ?? 'null'} signal=${signal ?? 'null'}.`); if (typeof code === 'number') { - resolve(code); + resolve({ code, signal: null, launchError: false }); return; } if (signal) { - resolve(1); + resolve({ code: 1, signal, launchError: false }); return; } - resolve(0); + resolve({ code: 0, signal: null, launchError: false }); }); }); @@ -421,7 +422,11 @@ async function main() { const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); appProc = child; - const exitCode = await exitPromise; + const exitInfo = await exitPromise; + const exitCode = typeof exitInfo?.code === 'number' ? exitInfo.code : 1; + console.error( + `Main command finished with code=${exitInfo?.code ?? 'null'} signal=${exitInfo?.signal ?? 'null'} launchError=${Boolean(exitInfo?.launchError)}.`, + ); await shutdown('SIGTERM'); exitOnce(exitCode); diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts deleted file mode 100644 index 5bfe152..0000000 --- a/src/app/api/whisper/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import type { TTSSentenceAlignment } from '@/types/tts'; -import { auth } from '@/lib/server/auth/auth'; -import { makeWhisperCacheKey, type WhisperRequestBody } from '@/lib/server/whisper/alignment'; -import { getCompute } from '@/lib/server/compute'; - -export const runtime = 'nodejs'; - -export async function POST(req: NextRequest) { - try { - const session = await auth?.api.getSession({ headers: req.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = (await req.json()) as WhisperRequestBody; - const { text, audio, lang } = body; - - if (!text || !audio || !Array.isArray(audio)) { - return NextResponse.json( - { error: 'Missing text or audio in request body' }, - { status: 400 } - ); - } - - const cacheKey = makeWhisperCacheKey(body); - const audioBuffer = new Uint8Array(audio).buffer; - - const alignments: TTSSentenceAlignment[] = (await getCompute().alignWords({ - audioBuffer, - text, - cacheKey, - lang, - })).alignments; - - return NextResponse.json({ alignments }, { status: 200 }); - } catch (error) { - console.error('Error in whisper route:', error); - return NextResponse.json( - { - error: 'WHISPER_ALIGNMENT_FAILED', - message: 'Failed to compute word-level alignment', - }, - { status: 500 } - ); - } -} diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index d737815..eb32a86 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -5,8 +5,6 @@ import type { AudiobookStatusResponse, CreateChapterPayload, VoicesResponse, - AlignmentPayload, - AlignmentResponse, TTSSegmentsEnsureRequest, TTSSegmentsEnsureResponse, } from '@/types/client'; @@ -208,23 +206,6 @@ export const getVoices = async (headers: HeadersInit): Promise = return await response.json(); }; -// --- Whisper API --- - - - -export const alignAudio = async (payload: AlignmentPayload): Promise => { - const response = await fetch('/api/whisper', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) return null; - return await response.json(); -}; - export const ensureTtsSegments = async ( payload: TTSSegmentsEnsureRequest, headers: TTSRequestHeaders, diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index f0700bb..b469a55 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -1,17 +1,12 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; import { LocalComputeBackend } from '@/lib/server/compute/local'; import { NoneComputeBackend } from '@/lib/server/compute/none'; +import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; let backend: ComputeBackend | null = null; -function readMode(): ComputeMode { - const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase(); - if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; - return 'local'; -} - function createBackend(): ComputeBackend { - const mode = readMode(); + const mode: ComputeMode = readComputeMode(); if (mode === 'none') return new NoneComputeBackend(); if (mode === 'worker') { throw new Error( @@ -27,11 +22,5 @@ export function getCompute(): ComputeBackend { } export function isComputeAvailable(): boolean { - const mode = readMode(); - if (mode === 'worker') { - throw new Error( - 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', - ); - } - return mode !== 'none'; + return isComputeModeAvailable(readComputeMode()); } diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index 2f95a98..a38bf6a 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -1,21 +1,21 @@ import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; -import { alignAudioWithText } from '@/lib/server/whisper/alignment'; -import { parsePdf } from '@/lib/server/pdf-layout/parsePdf'; export class LocalComputeBackend implements ComputeBackend { readonly mode = 'local' as const; async alignWords(input: WhisperAlignInput): Promise { + const { alignAudioWithText } = await import('@/lib/server/whisper/alignment'); const alignments = await alignAudioWithText( input.audioBuffer, input.text, input.cacheKey, - { engine: 'whisper.cpp', lang: input.lang }, + { lang: input.lang }, ); return { alignments }; } async parsePdfLayout(input: PdfLayoutInput) { + const { parsePdf } = await import('@/lib/server/pdf-layout/parsePdf'); return parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes }); } } diff --git a/src/lib/server/compute/mode.ts b/src/lib/server/compute/mode.ts new file mode 100644 index 0000000..170d6c9 --- /dev/null +++ b/src/lib/server/compute/mode.ts @@ -0,0 +1,16 @@ +import type { ComputeMode } from '@/lib/server/compute/types'; + +export function readComputeMode(): ComputeMode { + const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase(); + if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; + return 'local'; +} + +export function isComputeModeAvailable(mode: ComputeMode): boolean { + if (mode === 'worker') { + throw new Error( + 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + ); + } + return mode !== 'none'; +} diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index 87889d8..e1585c3 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,7 +6,7 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; -import { isComputeAvailable } from '@/lib/server/compute'; +import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; export type ResolvedRuntimeConfig = RuntimeConfig & { computeAvailable: boolean; @@ -29,7 +29,7 @@ export async function getResolvedRuntimeConfig(): Promise const values = await getRuntimeConfig(); return { ...values, - computeAvailable: isComputeAvailable(), + computeAvailable: isComputeModeAvailable(readComputeMode()), }; } diff --git a/src/lib/server/whisper/alignment-mapping.ts b/src/lib/server/whisper/alignment-mapping.ts new file mode 100644 index 0000000..52c5707 --- /dev/null +++ b/src/lib/server/whisper/alignment-mapping.ts @@ -0,0 +1,46 @@ +import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts'; +import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; + +export interface WhisperWord { + start: number; + end: number; + word: string; +} + +export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment { + const normalizedSentence = preprocessSentenceForAudio(sentence); + const lowerSentence = normalizedSentence.toLowerCase(); + let cursor = 0; + + const alignedWords: TTSSentenceWord[] = words.map((w) => { + const token = w.word.trim(); + if (!token) { + return { + text: '', + startSec: w.start, + endSec: w.end, + charStart: cursor, + charEnd: cursor, + }; + } + + const idx = lowerSentence.indexOf(token.toLowerCase(), cursor); + const start = idx >= 0 ? idx : cursor; + const end = Math.min(normalizedSentence.length, start + token.length); + cursor = Math.max(cursor, end); + + return { + text: token, + startSec: w.start, + endSec: w.end, + charStart: start, + charEnd: end, + }; + }).filter((word) => word.text.length > 0); + + return { + sentence, + sentenceIndex: 0, + words: alignedWords, + }; +} diff --git a/src/lib/server/whisper/alignment.ts b/src/lib/server/whisper/alignment.ts index 488b3c5..d0d8f14 100644 --- a/src/lib/server/whisper/alignment.ts +++ b/src/lib/server/whisper/alignment.ts @@ -1,21 +1,36 @@ import { createHash, randomUUID } from 'crypto'; -import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { spawn } from 'child_process'; -import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts'; -import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; +import * as ort from 'onnxruntime-node'; +import { Tokenizer } from '@huggingface/tokenizers'; +import JSZip from 'jszip'; +import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '@/types/tts'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; +import { + mapWordsToSentenceOffsets, + type WhisperWord, +} from '@/lib/server/whisper/alignment-mapping'; +import { buildGoertzelCoefficients, goertzelPower } from '@/lib/server/whisper/spectral'; +import { + buildWordsFromTimestampedTokens, + extractTokenStartTimestamps, +} from '@/lib/server/whisper/token-timestamps'; +import { + ensureWhisperModel, + WHISPER_CONFIG_PATH, + WHISPER_GENERATION_CONFIG_PATH, + WHISPER_TOKENIZER_CONFIG_PATH, + WHISPER_TOKENIZER_PATH, + WHISPER_ENCODER_MODEL_PATH, + WHISPER_DECODER_MERGED_MODEL_PATH, + WHISPER_DECODER_WITH_PAST_MODEL_PATH, +} from '@/lib/server/whisper/ensureModel'; interface WhisperAlignmentOptions { - engine?: 'whisper.cpp'; lang?: string; -} - -interface WhisperWord { - start: number; - end: number; - word: string; + textHint?: string; } export interface WhisperRequestBody { @@ -24,362 +39,962 @@ export interface WhisperRequestBody { lang?: string; } -const alignmentCache = new Map(); - -const MODEL_NAME = 'ggml-tiny.en.bin'; -const MODEL_URL = - 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin'; -const DOCSTORE_DIR = join(process.cwd(), 'docstore'); -const MODEL_DIR = join(DOCSTORE_DIR, 'model'); -const MODEL_PATH = join(MODEL_DIR, MODEL_NAME); -const modelReadyPromises = new Map>(); - -async function ensureModelAvailable(): Promise { - try { - await access(MODEL_PATH); - return; - } catch { - // continue - } - - const existing = modelReadyPromises.get(MODEL_PATH); - if (existing) return existing; - - const promise = (async () => { - try { - await access(MODEL_PATH); - return; - } catch { - // still missing - } - - await mkdir(MODEL_DIR, { recursive: true }); - - const res = await fetch(MODEL_URL); - if (!res.ok) { - throw new Error( - `Failed to download Whisper model from ${MODEL_URL}: ${res.status} ${res.statusText}` - ); - } - - const arrayBuffer = await res.arrayBuffer(); - await writeFile(MODEL_PATH, Buffer.from(arrayBuffer)); - })(); - - modelReadyPromises.set(MODEL_PATH, promise); - return promise; +interface WhisperRuntime { + encoder: ort.InferenceSession; + decoderMerged: ort.InferenceSession; + decoderWithPast: ort.InferenceSession; + tokenizer: Tokenizer; + promptStartToken: number; + defaultLanguageToken: number; + transcribeToken: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; + maxDecodeSteps: number; + suppressTokens: Set; + beginSuppressTokens: Set; + alignmentHeads: Array<[number, number]>; + prefillFetches: string[]; + stepFetches: string[]; } -async function runWhisperCpp( - wavPath: string, - opts: WhisperAlignmentOptions -): Promise { - const binary = process.env.WHISPER_CPP_BIN; - if (!binary) { - throw new Error( - 'Whisper.cpp binary path not configured. Set WHISPER_CPP_BIN to the compiled binary.' - ); +type WhisperAlignmentState = { + alignmentCache: Map; + alignmentInFlight: Map>; + runtimePromise: Promise | null; + alignMutex: Promise; + pendingAlignments: number; + officialMelFilters: Float32Array[] | null; + emptyPastFeedsTemplate: Record | null; +}; + +const WHISPER_ALIGNMENT_STATE_KEY = '__openreaderWhisperAlignmentStateV1'; +const g = globalThis as typeof globalThis & Record; +const state = (() => { + const existing = g[WHISPER_ALIGNMENT_STATE_KEY] as WhisperAlignmentState | undefined; + if (existing) return existing; + const created: WhisperAlignmentState = { + alignmentCache: new Map(), + alignmentInFlight: new Map>(), + runtimePromise: null, + alignMutex: Promise.resolve(), + pendingAlignments: 0, + officialMelFilters: null, + emptyPastFeedsTemplate: null, + }; + g[WHISPER_ALIGNMENT_STATE_KEY] = created; + return created; +})(); +const alignmentCache = state.alignmentCache; +const alignmentInFlight = state.alignmentInFlight; +const ALIGNMENT_CACHE_MAX_ENTRIES = 256; +const MAX_DECODE_STEPS_CAP = 128; +const ALIGNMENT_TIMEOUT_MS = 25000; +const FFMPEG_DECODE_TIMEOUT_MS = 10000; + +const SAMPLE_RATE = 16000; +const N_FFT = 400; +const HOP_LENGTH = 160; +const CHUNK_LENGTH_SECONDS = 30; +const N_SAMPLES = CHUNK_LENGTH_SECONDS * SAMPLE_RATE; +const N_FRAMES = N_SAMPLES / HOP_LENGTH; +const N_MELS = 80; +const WHISPER_NUM_HEADS = 8; +const WHISPER_HEAD_DIM = 64; +const WHISPER_NUM_LAYERS = 6; +const MEL_FILTER_BINS = (N_FFT / 2) + 1; + +const hannWindow = buildHannWindow(N_FFT); +const goertzelCoefficients = buildGoertzelCoefficients(MEL_FILTER_BINS, N_FFT); + +const MEL_FILTERS_NPZ_PATH = join(process.cwd(), 'src/lib/server/whisper/model/mel_filters.npz'); + +function buildHannWindow(length: number): Float32Array { + const window = new Float32Array(length); + for (let i = 0; i < length; i += 1) { + window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / length); + } + return window; +} + +function parseNpyFloat32(bytes: Uint8Array): { shape: number[]; data: Float32Array } { + if (bytes.length < 12) { + throw new Error('Invalid NPY payload: too short'); + } + const magic = String.fromCharCode(...bytes.slice(0, 6)); + if (magic !== '\u0093NUMPY') { + throw new Error('Invalid NPY payload: missing magic header'); } - await ensureModelAvailable(); + const major = bytes[6]; + const headerLength = major <= 1 + ? new DataView(bytes.buffer, bytes.byteOffset + 8, 2).getUint16(0, true) + : new DataView(bytes.buffer, bytes.byteOffset + 8, 4).getUint32(0, true); + const headerOffset = major <= 1 ? 10 : 12; + const header = Buffer.from(bytes.slice(headerOffset, headerOffset + headerLength)).toString('latin1'); - return new Promise((resolve, reject) => { - const jsonBase = `${wavPath}.json_out`; - const jsonPath = `${jsonBase}.json`; - const args = [ - '-m', - MODEL_PATH, - '-f', - wavPath, - '-of', - jsonBase, - '-ojf', - '-np', - ]; + const descrMatch = header.match(/'descr':\s*'([^']+)'/); + if (!descrMatch || descrMatch[1] !== ' token.trim()) + .filter(Boolean) + .map((token) => Number(token)) + .filter((n) => Number.isFinite(n) && n > 0); + + const dataOffset = headerOffset + headerLength; + const dataBytes = bytes.slice(dataOffset); + const totalFloats = Math.floor(dataBytes.byteLength / 4); + const data = new Float32Array(totalFloats); + const view = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength); + for (let i = 0; i < totalFloats; i += 1) { + data[i] = view.getFloat32(i * 4, true); + } + + return { shape, data }; +} + +async function loadOfficialMelFilters(): Promise { + if (state.officialMelFilters) return state.officialMelFilters; + + const npzBytes = await readFile(MEL_FILTERS_NPZ_PATH); + const zip = await JSZip.loadAsync(npzBytes); + const mel80 = zip.file('mel_80.npy'); + if (!mel80) { + throw new Error('OpenAI mel filter asset is missing mel_80.npy'); + } + + const raw = await mel80.async('uint8array'); + const parsed = parseNpyFloat32(raw); + const [rows, cols] = parsed.shape; + if (rows !== N_MELS || cols !== MEL_FILTER_BINS) { + throw new Error(`Unexpected mel filter shape: [${rows}, ${cols}]`); + } + + const filters: Float32Array[] = []; + for (let row = 0; row < rows; row += 1) { + const start = row * cols; + filters.push(parsed.data.slice(start, start + cols)); + } + + state.officialMelFilters = filters; + return filters; +} + +function pcm16ToFloat32(buffer: Buffer): Float32Array { + const view = new Int16Array(buffer.buffer, buffer.byteOffset, Math.floor(buffer.byteLength / 2)); + const out = new Float32Array(view.length); + for (let i = 0; i < view.length; i += 1) { + out[i] = view[i] / 32768; + } + return out; +} + +function padOrTrimAudio(samples: Float32Array): Float32Array { + if (samples.length === N_SAMPLES) return samples; + if (samples.length > N_SAMPLES) return samples.subarray(0, N_SAMPLES); + + const padded = new Float32Array(N_SAMPLES); + padded.set(samples, 0); + return padded; +} + +function reflectPad(audio: Float32Array, pad: number): Float32Array { + const out = new Float32Array(audio.length + (2 * pad)); + out.set(audio, pad); + + // Match PyTorch reflect padding (exclude edge sample). + for (let i = 0; i < pad; i += 1) { + out[pad - 1 - i] = audio[Math.min(audio.length - 1, i + 1)]; + out[pad + audio.length + i] = audio[Math.max(0, audio.length - 2 - i)]; + } + + return out; +} + +function computeLogMelSpectrogram(audioSamples: Float32Array): ort.Tensor { + if (!state.officialMelFilters) { + throw new Error('Whisper mel filters not loaded'); + } + + const paddedAudio = reflectPad(audioSamples, N_FFT / 2); + const stftFrames = N_FRAMES + 1; + const frameCount = N_FRAMES; + const freqBins = MEL_FILTER_BINS; + + const melSpec = Array.from({ length: N_MELS }, () => new Float32Array(frameCount)); + const frame = new Float32Array(N_FFT); + const power = new Float32Array(freqBins); + + for (let frameIndex = 0; frameIndex < stftFrames; frameIndex += 1) { + const offset = frameIndex * HOP_LENGTH; + + for (let i = 0; i < N_FFT; i += 1) { + frame[i] = (paddedAudio[offset + i] ?? 0) * hannWindow[i]; } - const child = spawn(binary, args); + for (let k = 0; k < freqBins; k += 1) { + power[k] = goertzelPower(frame, goertzelCoefficients[k]); + } + + if (frameIndex === stftFrames - 1) { + continue; + } + + for (let melIndex = 0; melIndex < N_MELS; melIndex += 1) { + const filter = state.officialMelFilters[melIndex]; + let total = 0; + for (let k = 0; k < freqBins; k += 1) { + total += filter[k] * power[k]; + } + melSpec[melIndex][frameIndex] = total; + } + } + + // Whisper normalization from openai/whisper/audio.py + let globalMaxLog = Number.NEGATIVE_INFINITY; + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const logVal = Math.log10(Math.max(1e-10, melSpec[i][j])); + if (logVal > globalMaxLog) globalMaxLog = logVal; + melSpec[i][j] = logVal; + } + } + + const floorVal = globalMaxLog - 8.0; + const flattened = new Float32Array(1 * N_MELS * frameCount); + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const clamped = Math.max(melSpec[i][j], floorVal); + flattened[(i * frameCount) + j] = (clamped + 4.0) / 4.0; + } + } + + return new ort.Tensor('float32', flattened, [1, N_MELS, frameCount]); +} + +async function decodeToPcm16(inputPath: string, outputPath: string): Promise { + await new Promise((resolve, reject) => { + const ffmpeg = spawn(getFFmpegPath(), [ + '-y', + '-i', + inputPath, + '-f', + 's16le', + '-ar', + String(SAMPLE_RATE), + '-ac', + '1', + outputPath, + ]); - let stdout = ''; let stderr = ''; - - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data) => { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + ffmpeg.kill('SIGKILL'); + }, FFMPEG_DECODE_TIMEOUT_MS); + ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); - child.on('error', (err) => { + ffmpeg.on('error', (err) => { + clearTimeout(timer); reject(err); }); - child.on('close', (code) => { - if (code !== 0) { - return reject( - new Error( - `whisper.cpp exited with code ${code}: ${stderr || stdout}` - ) - ); + ffmpeg.on('close', (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`ffmpeg decode timed out after ${FFMPEG_DECODE_TIMEOUT_MS}ms`)); + return; + } + if (code === 0) { + resolve(); + } else { + reject(new Error(`ffmpeg decode failed with code ${code}: ${stderr}`)); } - - readFile(jsonPath, 'utf-8') - .then((content: string) => { - const words: WhisperWord[] = []; - const parsed = JSON.parse(content) as { - transcription?: Array<{ - text?: string; - timestamps?: { from?: string; to?: string }; - offsets?: { from?: number; to?: number }; - tokens?: Array<{ - text?: string; - timestamps?: { from?: string; to?: string }; - offsets?: { from?: number; to?: number }; - }>; - }>; - }; - - const transcription = parsed.transcription; - - const parseTimecode = (value?: string): number | null => { - if (!value) return null; - const m = value.match(/(\d+):(\d+):(\d+),(\d+)/); - if (!m) return null; - const h = Number(m[1]); - const min = Number(m[2]); - const s = Number(m[3]); - const ms = Number(m[4]); - if ( - Number.isNaN(h) - || Number.isNaN(min) - || Number.isNaN(s) - || Number.isNaN(ms) - ) { - return null; - } - return h * 3600 + min * 60 + s + ms / 1000; - }; - - if (Array.isArray(transcription)) { - for (const seg of transcription) { - const segText = (seg.text || '').trim(); - const segStartSecFromTs = parseTimecode( - seg.timestamps?.from - ); - const segEndSecFromTs = parseTimecode(seg.timestamps?.to); - const segStartSecFromMs = - typeof seg.offsets?.from === 'number' - ? seg.offsets.from / 1000 - : null; - const segEndSecFromMs = - typeof seg.offsets?.to === 'number' - ? seg.offsets.to / 1000 - : null; - - const segStartSec = - segStartSecFromTs - ?? segStartSecFromMs - ?? 0; - const segEndSec = - segEndSecFromTs - ?? segEndSecFromMs - ?? segStartSec; - - const tokens = Array.isArray(seg.tokens) - ? seg.tokens - : []; - - if (tokens.length > 0) { - for (const token of tokens) { - const rawText = token.text || ''; - const tokenText = rawText.trim(); - if (!tokenText || /^\[.*\]$/.test(tokenText)) continue; - - const tokStartSecFromTs = parseTimecode( - token.timestamps?.from - ); - const tokEndSecFromTs = parseTimecode( - token.timestamps?.to - ); - const tokStartSecFromMs = - typeof token.offsets?.from === 'number' - ? token.offsets.from / 1000 - : null; - const tokEndSecFromMs = - typeof token.offsets?.to === 'number' - ? token.offsets.to / 1000 - : null; - - const startSec = - tokStartSecFromTs - ?? tokStartSecFromMs - ?? segStartSec; - const endSec = - tokEndSecFromTs - ?? tokEndSecFromMs - ?? segEndSec; - - words.push({ - word: tokenText, - start: startSec, - end: endSec, - }); - } - } else if (segText) { - const segTokens = segText.split(/\s+/).filter(Boolean); - if (segTokens.length) { - const totalDur = Math.max(segEndSec - segStartSec, 0); - const step = - segTokens.length > 0 - ? totalDur / segTokens.length - : 0; - segTokens.forEach((token, index) => { - const wStart = - step > 0 - ? segStartSec + step * index - : segStartSec; - const wEnd = - step > 0 - ? index === segTokens.length - 1 - ? segEndSec - : segStartSec + step * (index + 1) - : segEndSec; - words.push({ - word: token, - start: wStart, - end: wEnd, - }); - }); - } - } - } - } - - resolve(words); - }) - .catch((err: unknown) => { - reject(err); - }); }); }); } -function mapWordsToSentenceOffsets( - sentence: string, - words: WhisperWord[] -): TTSSentenceAlignment { - const normalizedSentence = preprocessSentenceForAudio(sentence); - let cursor = 0; +function parseLanguageCode(lang?: string): string | null { + if (!lang) return null; + const trimmed = lang.trim().toLowerCase(); + if (!trimmed) return null; + if (trimmed.includes('-')) return trimmed.split('-')[0] || null; + if (trimmed.includes('_')) return trimmed.split('_')[0] || null; + return trimmed; +} - const alignedWords = words.map((w) => { - const token = w.word.trim(); - if (!token) { - return { - text: '', - startSec: w.start, - endSec: w.end, - charStart: cursor, - charEnd: cursor, - }; +function tensorFromInt64(values: number[]): ort.Tensor { + return new ort.Tensor('int64', BigInt64Array.from(values.map((v) => BigInt(v))), [1, values.length]); +} + +function disposeTensor(tensor: ort.Tensor | undefined | null): void { + if (!tensor) return; + try { + tensor.dispose(); + } catch { + // Best-effort cleanup: ignore disposal errors during fallback path. + } +} + +function disposeTensorMap(tensors: Record): void { + for (const tensor of Object.values(tensors)) { + disposeTensor(tensor); + } +} + +function computeAdaptiveDecodeStepLimit(maxDecodeSteps: number, textHint?: string): number { + const normalized = (textHint ?? '').trim(); + if (!normalized) return Math.min(maxDecodeSteps, 96); + + const chars = normalized.length; + const words = normalized.split(/\s+/).filter(Boolean).length; + const estTokens = Math.max(words * 3, Math.ceil(chars / 2)); + const adaptive = Math.max(64, Math.min(maxDecodeSteps, estTokens + 24)); + return adaptive; +} + +function assertWithinDeadline(deadlineMs: number): void { + if (Date.now() > deadlineMs) { + throw new Error(`Whisper alignment timed out after ${ALIGNMENT_TIMEOUT_MS}ms`); + } +} + +function makeInFlightCoalesceKey(audioBuffer: TTSAudioBuffer, text: string, lang?: string): string { + const bytes = new Uint8Array(audioBuffer); + const span = 4096; + const head = bytes.subarray(0, Math.min(span, bytes.length)); + const tailStart = Math.max(0, bytes.length - span); + const tail = bytes.subarray(tailStart); + return createHash('sha256') + .update(text) + .update('\0') + .update(lang ?? '') + .update('\0') + .update(String(bytes.length)) + .update('\0') + .update(head) + .update('\0') + .update(tail) + .digest('hex'); +} + +function buildEmptyPastFeeds() { + if (state.emptyPastFeedsTemplate) return state.emptyPastFeedsTemplate; + + const feeds: Record = {}; + const emptyDecoderPast = new Float32Array(0); + const emptyEncoderPast = new Float32Array(1 * WHISPER_NUM_HEADS * 1500 * WHISPER_HEAD_DIM); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + feeds[`past_key_values.${i}.decoder.key`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.decoder.value`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + + // First pass still expects encoder KV inputs in the merged decoder graph. + feeds[`past_key_values.${i}.encoder.key`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.encoder.value`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + } + + state.emptyPastFeedsTemplate = feeds; + return state.emptyPastFeedsTemplate; +} + +function argmax(values: Float32Array): number | null { + let bestIdx = 0; + let bestScore = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < values.length; i += 1) { + const score = values[i]; + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + return Number.isFinite(bestScore) ? bestIdx : null; +} + +function applyTokenSuppression(logits: Float32Array, tokens: Set) { + for (const tokenId of tokens) { + if (tokenId >= 0 && tokenId < logits.length) { + logits[tokenId] = Number.NEGATIVE_INFINITY; + } + } +} + +function logSoftmax(input: Float32Array): Float32Array { + let max = Number.NEGATIVE_INFINITY; + for (let i = 0; i < input.length; i += 1) { + if (input[i] > max) max = input[i]; + } + if (!Number.isFinite(max)) { + return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY); + } + + let sum = 0; + for (let i = 0; i < input.length; i += 1) { + sum += Math.exp(input[i] - max); + } + const logSum = Math.log(sum); + + const out = new Float32Array(input.length); + for (let i = 0; i < input.length; i += 1) { + out[i] = input[i] - max - logSum; + } + return out; +} + +function applyWhisperTimestampLogitsRules(input: { + logits: Float32Array; + generated: number[]; + beginIndex: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; +}) { + const { + logits, + generated, + beginIndex, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + } = input; + + if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) { + logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY; + } + + if (generated.length === beginIndex) { + const upper = Math.min(timestampBeginTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const seq = generated.slice(beginIndex); + const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId; + const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId; + + if (lastWasTimestamp) { + if (penultimateWasTimestamp) { + for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } else { + const upper = Math.min(eosTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + } + + if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) { + const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex; + for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const textUpper = Math.min(timestampBeginTokenId, logits.length); + if (textUpper <= 0 || textUpper >= logits.length) return; + + const logprobs = logSoftmax(logits); + + let maxTextTokenLogprob = Number.NEGATIVE_INFINITY; + for (let i = 0; i < textUpper; i += 1) { + if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i]; + } + + let timestampProbMass = 0; + for (let i = textUpper; i < logprobs.length; i += 1) { + timestampProbMass += Math.exp(logprobs[i]); + } + const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY; + + if (timestampLogprob > maxTextTokenLogprob) { + for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } +} + +async function getRuntime(): Promise { + if (state.runtimePromise) return state.runtimePromise; + + state.runtimePromise = (async () => { + await ensureWhisperModel(); + await loadOfficialMelFilters(); + + const [configRaw, generationRaw, tokenizerJsonRaw, tokenizerConfigRaw] = await Promise.all([ + readFile(WHISPER_CONFIG_PATH, 'utf8'), + readFile(WHISPER_GENERATION_CONFIG_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_CONFIG_PATH, 'utf8'), + ]); + + const config = JSON.parse(configRaw) as { + decoder_start_token_id?: number; + eos_token_id?: number; + forced_decoder_ids?: Array<[number, number | null]>; + }; + + const generationConfig = JSON.parse(generationRaw) as { + no_timestamps_token_id?: number; + max_initial_timestamp_index?: number; + suppress_tokens?: number[]; + begin_suppress_tokens?: number[]; + max_length?: number; + alignment_heads?: Array<[number, number]>; + }; + + const tokenizer = new Tokenizer(JSON.parse(tokenizerJsonRaw), JSON.parse(tokenizerConfigRaw)); + + const promptStartToken = Number(config.decoder_start_token_id ?? 50258); + const eosTokenId = Number(config.eos_token_id ?? 50257); + const noTimestampsTokenId = Number(generationConfig.no_timestamps_token_id ?? 50363); + const timestampBeginTokenId = noTimestampsTokenId + 1; + const maxInitialTimestampIndex = Number(generationConfig.max_initial_timestamp_index ?? 50); + const configuredMaxDecodeSteps = Number(generationConfig.max_length ?? 448); + const maxDecodeSteps = Math.min(configuredMaxDecodeSteps, MAX_DECODE_STEPS_CAP); + const alignmentHeads = Array.isArray(generationConfig.alignment_heads) + ? generationConfig.alignment_heads + .filter((head): head is [number, number] => Array.isArray(head) && head.length === 2) + .map(([layer, head]) => [Number(layer), Number(head)] as [number, number]) + : []; + + const forcedDecoder = Array.isArray(config.forced_decoder_ids) ? config.forced_decoder_ids : []; + const defaultLanguageFromForced = forcedDecoder.find(([index, id]) => index === 1 && typeof id === 'number')?.[1] ?? null; + const transcribeFromForced = forcedDecoder.find(([index, id]) => index === 2 && typeof id === 'number')?.[1] ?? null; + + const defaultLanguageToken = Number(defaultLanguageFromForced ?? tokenizer.token_to_id('<|en|>') ?? 50259); + const transcribeToken = Number(transcribeFromForced ?? tokenizer.token_to_id('<|transcribe|>') ?? 50359); + + const stableSessionOptions: ort.InferenceSession.SessionOptions = { + executionProviders: ['cpu'], + graphOptimizationLevel: 'disabled', + intraOpNumThreads: 1, + interOpNumThreads: 1, + executionMode: 'sequential', + enableCpuMemArena: false, + enableMemPattern: false, + }; + + const encoder = await ort.InferenceSession.create(WHISPER_ENCODER_MODEL_PATH, stableSessionOptions); + const decoderMerged = await ort.InferenceSession.create(WHISPER_DECODER_MERGED_MODEL_PATH, stableSessionOptions); + const decoderWithPast = await ort.InferenceSession.create(WHISPER_DECODER_WITH_PAST_MODEL_PATH, stableSessionOptions); + + const alignmentLayers = [...new Set(alignmentHeads.map(([layer]) => layer))]; + const prefillFetches: string[] = ['logits']; + const stepFetches: string[] = ['logits']; + const mergedOutputNames = new Set(decoderMerged.outputNames); + const withPastOutputNames = new Set(decoderWithPast.outputNames); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + const decoderKey = `present.${i}.decoder.key`; + const decoderValue = `present.${i}.decoder.value`; + if (mergedOutputNames.has(decoderKey)) prefillFetches.push(decoderKey); + if (mergedOutputNames.has(decoderValue)) prefillFetches.push(decoderValue); + if (withPastOutputNames.has(decoderKey)) stepFetches.push(decoderKey); + if (withPastOutputNames.has(decoderValue)) stepFetches.push(decoderValue); + + const encoderKey = `present.${i}.encoder.key`; + const encoderValue = `present.${i}.encoder.value`; + if (mergedOutputNames.has(encoderKey)) prefillFetches.push(encoderKey); + if (mergedOutputNames.has(encoderValue)) prefillFetches.push(encoderValue); } - const idx = normalizedSentence - .toLowerCase() - .indexOf(token.toLowerCase(), cursor); - - const start = - idx !== -1 - ? idx - : cursor; - const end = start + token.length; - - cursor = end; + for (const layer of alignmentLayers) { + const key = `cross_attentions.${layer}`; + if (mergedOutputNames.has(key)) prefillFetches.push(key); + if (withPastOutputNames.has(key)) stepFetches.push(key); + } return { - text: token, - startSec: w.start, - endSec: w.end, - charStart: start, - charEnd: end, + encoder, + decoderMerged, + decoderWithPast, + tokenizer, + promptStartToken, + defaultLanguageToken, + transcribeToken, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + maxDecodeSteps, + suppressTokens: new Set((generationConfig.suppress_tokens ?? []).map((v) => Number(v))), + beginSuppressTokens: new Set((generationConfig.begin_suppress_tokens ?? []).map((v) => Number(v))), + alignmentHeads, + prefillFetches, + stepFetches, }; + })().catch((error) => { + state.runtimePromise = null; + throw error; }); - return { - sentence, - sentenceIndex: 0, - words: alignedWords.filter((w) => w.text.length > 0), - }; + return state.runtimePromise; +} + +function resolveLanguageToken(runtime: WhisperRuntime, lang?: string): number { + const parsed = parseLanguageCode(lang); + if (!parsed) return runtime.defaultLanguageToken; + + const candidate = runtime.tokenizer.token_to_id(`<|${parsed}|>`); + return typeof candidate === 'number' ? candidate : runtime.defaultLanguageToken; +} + +async function runWhisperOnnx( + audioSamples: Float32Array, + opts: WhisperAlignmentOptions, + numFrames: number, + deadlineMs: number, +): Promise { + assertWithinDeadline(deadlineMs); + const runtime = await getRuntime(); + const decodeStepLimit = computeAdaptiveDecodeStepLimit(runtime.maxDecodeSteps, opts.textHint); + const mel = computeLogMelSpectrogram(audioSamples); + const encoderPast: Record = {}; + const decoderPast: Record = {}; + const crossAttentions: Record = {}; + let encoderHidden: ort.Tensor | null = null; + let outputs: Record | null = null; + + try { + const encoderOutputs = await runtime.encoder.run({ + input_features: mel, + }, ['last_hidden_state']); + encoderHidden = encoderOutputs.last_hidden_state; + + const languageToken = resolveLanguageToken(runtime, opts.lang); + const promptTokens = [ + runtime.promptStartToken, + languageToken, + runtime.transcribeToken, + ]; + + const generated: number[] = [...promptTokens]; + const emptyPastFeeds = buildEmptyPastFeeds(); + type LayerChunk = { + data: Float32Array; + heads: number; + seqLen: number; + frames: number; + }; + const selectedHeadsByLayer = new Map(); + for (const [layer, head] of runtime.alignmentHeads) { + const existing = selectedHeadsByLayer.get(layer) ?? []; + if (!existing.includes(head)) existing.push(head); + selectedHeadsByLayer.set(layer, existing); + } + for (const [layer, heads] of selectedHeadsByLayer) { + heads.sort((a, b) => a - b); + selectedHeadsByLayer.set(layer, heads); + } + const crossAttentionChunks = new Map(); + + const captureCrossAttentions = (stepOutputs: Record, prefill = false) => { + for (const [layer, selectedHeads] of selectedHeadsByLayer) { + const key = `cross_attentions.${layer}`; + const tensor = stepOutputs[key]; + if (!tensor) continue; + const [, , seqLen, frames] = tensor.dims; + const data = tensor.data as Float32Array; + const rowsToKeep = prefill ? seqLen : 1; + const seqStart = prefill ? 0 : Math.max(0, seqLen - 1); + const copied = new Float32Array(selectedHeads.length * rowsToKeep * frames); + for (let h = 0; h < selectedHeads.length; h += 1) { + const sourceHead = selectedHeads[h]!; + for (let s = 0; s < rowsToKeep; s += 1) { + const sourceSeq = seqStart + s; + for (let f = 0; f < frames; f += 1) { + const src = (((sourceHead * seqLen) + sourceSeq) * frames) + f; + const dst = (((h * rowsToKeep) + s) * frames) + f; + copied[dst] = data[src] ?? 0; + } + } + } + const list = crossAttentionChunks.get(layer) ?? []; + list.push({ data: copied, heads: selectedHeads.length, seqLen: rowsToKeep, frames }); + crossAttentionChunks.set(layer, list); + } + }; + const beginIndex = promptTokens.length; + + // Prefill: run prompt in merged decoder (non-cache branch), identical to first + // forward pass in transformers.js/transformers generation. + const prefillInputIds = tensorFromInt64(generated); + const prefillUseCacheBranch = new ort.Tensor('bool', Uint8Array.from([0]), [1]); + const prefillFeeds: Record = { + input_ids: prefillInputIds, + encoder_hidden_states: encoderHidden, + use_cache_branch: prefillUseCacheBranch, + ...emptyPastFeeds, + }; + try { + assertWithinDeadline(deadlineMs); + outputs = await runtime.decoderMerged.run(prefillFeeds, runtime.prefillFetches); + } finally { + disposeTensor(prefillInputIds); + disposeTensor(prefillUseCacheBranch); + } + captureCrossAttentions(outputs, true); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + encoderPast[`past_key_values.${i}.encoder.key`] = outputs[`present.${i}.encoder.key`]; + encoderPast[`past_key_values.${i}.encoder.value`] = outputs[`present.${i}.encoder.value`]; + decoderPast[`past_key_values.${i}.decoder.key`] = outputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = outputs[`present.${i}.decoder.value`]; + } + + for (let step = 0; step < decodeStepLimit; step += 1) { + assertWithinDeadline(deadlineMs); + if (!outputs) break; + const logits = outputs.logits; + const logitsData = logits.data as Float32Array; + const vocabSize = logits.dims[2] ?? 0; + const offset = logitsData.length - vocabSize; + const lastLogits = logitsData.subarray(offset); + + applyTokenSuppression(lastLogits, runtime.suppressTokens); + if (generated.length === beginIndex) { + applyTokenSuppression(lastLogits, runtime.beginSuppressTokens); + } + applyWhisperTimestampLogitsRules({ + logits: lastLogits, + generated, + beginIndex, + eosTokenId: runtime.eosTokenId, + noTimestampsTokenId: runtime.noTimestampsTokenId, + timestampBeginTokenId: runtime.timestampBeginTokenId, + maxInitialTimestampIndex: runtime.maxInitialTimestampIndex, + }); + + const nextToken = argmax(lastLogits) ?? runtime.eosTokenId; + generated.push(nextToken); + if (nextToken === runtime.eosTokenId) break; + + const previousDecoderPast = { ...decoderPast }; + const stepInputIds = tensorFromInt64([nextToken]); + const stepFeeds: Record = { + input_ids: stepInputIds, + ...previousDecoderPast, + ...encoderPast, + }; + let nextOutputs: Record; + try { + assertWithinDeadline(deadlineMs); + nextOutputs = await runtime.decoderWithPast.run(stepFeeds, runtime.stepFetches); + } finally { + disposeTensor(stepInputIds); + } + captureCrossAttentions(nextOutputs, false); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + decoderPast[`past_key_values.${i}.decoder.key`] = nextOutputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = nextOutputs[`present.${i}.decoder.value`]; + } + + disposeTensorMap(previousDecoderPast); + disposeTensor(outputs.logits); + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + outputs = nextOutputs; + } + + if (crossAttentionChunks.size === 0) { + return []; + } + + const remappedAlignmentHeads: Array<[number, number]> = runtime.alignmentHeads + .map(([layer, head]) => { + const selectedHeads = selectedHeadsByLayer.get(layer) ?? []; + const remappedHead = selectedHeads.indexOf(head); + if (remappedHead < 0) return null; + return [layer, remappedHead] as [number, number]; + }) + .filter((pair): pair is [number, number] => pair !== null); + + for (let layer = 0; layer < WHISPER_NUM_LAYERS; layer += 1) { + const chunks = crossAttentionChunks.get(layer); + if (!chunks || !chunks.length) continue; + + const heads = chunks[0].heads; + const frames = chunks[0].frames; + const concatSeqLen = chunks.reduce((sum, chunk) => sum + chunk.seqLen, 0); + const merged = new Float32Array(1 * heads * concatSeqLen * frames); + let seqOffset = 0; + + for (const chunk of chunks) { + const { data, seqLen, frames: tensorFrames } = chunk; + const copyFrames = Math.min(frames, tensorFrames); + + for (let h = 0; h < heads; h += 1) { + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < copyFrames; f += 1) { + const src = (((h * seqLen) + s) * tensorFrames) + f; + const dst = (((h * concatSeqLen) + (seqOffset + s)) * frames) + f; + merged[dst] = data[src] ?? 0; + } + } + } + seqOffset += seqLen; + } + + crossAttentions[`cross_attentions.${layer}`] = new ort.Tensor('float32', merged, [1, heads, concatSeqLen, frames]); + } + + const tokenStartTimestamps = extractTokenStartTimestamps({ + crossAttentions, + decoderLayers: WHISPER_NUM_LAYERS, + alignmentHeads: remappedAlignmentHeads, + numFrames, + numInputIds: promptTokens.length, + timePrecision: 0.02, + sequenceLength: generated.length, + }); + + const timedWords = buildWordsFromTimestampedTokens({ + tokens: generated, + tokenStartTimestamps, + tokenizer: runtime.tokenizer, + eosTokenId: runtime.eosTokenId, + promptLength: promptTokens.length, + timestampBeginTokenId: runtime.timestampBeginTokenId, + timePrecision: 0.02, + language: parseLanguageCode(opts.lang) ?? 'english', + }); + + const maxSec = Math.max(0, numFrames * 0.02); + return timedWords.map((word) => ({ + word: word.word, + start: Math.min(maxSec, Math.max(0, word.startSec)), + end: Math.min(maxSec, Math.max(0, word.endSec)), + })); + } finally { + disposeTensor(mel); + if (outputs?.logits) disposeTensor(outputs.logits); + if (outputs) { + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + } + disposeTensorMap(crossAttentions); + disposeTensorMap(decoderPast); + disposeTensorMap(encoderPast); + disposeTensor(encoderHidden); + } } export async function alignAudioWithText( audioBuffer: TTSAudioBuffer, text: string, cacheKey?: string, - opts: WhisperAlignmentOptions = {} + opts: WhisperAlignmentOptions = {}, ): Promise { - if (!text.trim()) { - return []; - } + if (!text.trim()) return []; if (cacheKey && alignmentCache.has(cacheKey)) { - return alignmentCache.get(cacheKey)!; + const cached = alignmentCache.get(cacheKey)!; + alignmentCache.delete(cacheKey); + alignmentCache.set(cacheKey, cached); + return cached; } - const tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-')); - const inputPath = join(tmpBase, `${randomUUID()}-input.bin`); - const wavPath = join(tmpBase, `${randomUUID()}-input.wav`); + if (cacheKey) { + const inFlight = alignmentInFlight.get(cacheKey); + if (inFlight) return inFlight; + } + const inFlightKey = cacheKey ?? makeInFlightCoalesceKey(audioBuffer, text, opts.lang); + const shared = alignmentInFlight.get(inFlightKey); + if (shared) return shared; - try { - await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); - - await new Promise((resolve, reject) => { - const ffmpeg = spawn(getFFmpegPath(), [ - '-y', - '-i', - inputPath, - '-ar', - '16000', - '-ac', - '1', - wavPath, - ]); - - let stderr = ''; - ffmpeg.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - ffmpeg.on('error', (err) => { - reject(err); - }); - - ffmpeg.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error(`ffmpeg failed with code ${code}: ${stderr}`) - ); - } - }); + state.pendingAlignments += 1; + const run = (async (): Promise => { + const deadlineMs = Date.now() + ALIGNMENT_TIMEOUT_MS; + const previous = state.alignMutex; + let release!: () => void; + state.alignMutex = new Promise((resolve) => { + release = resolve; }); - const words = await runWhisperCpp(wavPath, opts); - const alignment = mapWordsToSentenceOffsets(text, words); - const result: TTSSentenceAlignment[] = [alignment]; + await previous; - if (cacheKey) { - alignmentCache.set(cacheKey, result); + // Another request with the same cache key may have completed while this one + // was waiting on the mutex. + if (cacheKey && alignmentCache.has(cacheKey)) { + const cached = alignmentCache.get(cacheKey)!; + alignmentCache.delete(cacheKey); + alignmentCache.set(cacheKey, cached); + release(); + return cached; } - return result; - } finally { - await rm(tmpBase, { recursive: true, force: true }).catch(() => {}); - } + let tmpBase = ''; + let inputPath = ''; + let pcmPath = ''; + + try { + tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-')); + inputPath = join(tmpBase, `${randomUUID()}-input.bin`); + pcmPath = join(tmpBase, `${randomUUID()}-input.pcm16`); + + await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); + await decodeToPcm16(inputPath, pcmPath); + + const pcmBytes = await readFile(pcmPath); + const decodedSamples = pcm16ToFloat32(pcmBytes); + const effectiveSampleLength = Math.min(decodedSamples.length, N_SAMPLES); + const effectiveFrameCount = Math.max(1, Math.floor((effectiveSampleLength / HOP_LENGTH) / 2)); + const normalizedAudio = padOrTrimAudio(decodedSamples); + + const words = await runWhisperOnnx( + normalizedAudio, + { ...opts, textHint: text }, + effectiveFrameCount, + deadlineMs, + ); + const alignment = mapWordsToSentenceOffsets(text, words); + const result: TTSSentenceAlignment[] = [alignment]; + + if (cacheKey) { + if (alignmentCache.has(cacheKey)) { + alignmentCache.delete(cacheKey); + } + alignmentCache.set(cacheKey, result); + while (alignmentCache.size > ALIGNMENT_CACHE_MAX_ENTRIES) { + const oldest = alignmentCache.keys().next().value; + if (!oldest) break; + alignmentCache.delete(oldest); + } + } + + return result; + } finally { + if (tmpBase) { + await rm(tmpBase, { recursive: true, force: true }).catch(() => {}); + } + release(); + state.pendingAlignments = Math.max(0, state.pendingAlignments - 1); + } + })(); + + alignmentInFlight.set(inFlightKey, run); + run.finally(() => { + if (alignmentInFlight.get(inFlightKey) === run) { + alignmentInFlight.delete(inFlightKey); + } + }); + return run; } export function makeWhisperCacheKey(input: WhisperRequestBody): string { @@ -389,7 +1004,7 @@ export function makeWhisperCacheKey(input: WhisperRequestBody): string { text: input.text, lang: input.lang || '', audioLen: input.audio?.length || 0, - }) + }), ) .digest('hex'); } diff --git a/src/lib/server/whisper/ensureModel.ts b/src/lib/server/whisper/ensureModel.ts new file mode 100644 index 0000000..6d1ae77 --- /dev/null +++ b/src/lib/server/whisper/ensureModel.ts @@ -0,0 +1,226 @@ +import path from 'path'; +import { createHash } from 'crypto'; +import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; +import manifest from '@/lib/server/whisper/model/manifest.json'; + +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped'); +const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/whisper/model/LICENSE.txt'); + +export const WHISPER_CONFIG_PATH = path.join(MODEL_DIR, 'config.json'); +export const WHISPER_GENERATION_CONFIG_PATH = path.join(MODEL_DIR, 'generation_config.json'); +export const WHISPER_TOKENIZER_PATH = path.join(MODEL_DIR, 'tokenizer.json'); +export const WHISPER_TOKENIZER_CONFIG_PATH = path.join(MODEL_DIR, 'tokenizer_config.json'); +export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_int8.onnx'); +export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_int8.onnx'); +export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_int8.onnx'); + +const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main'; + +const DEFAULT_URLS: Record = { + 'config.json': `${BASE_MODEL_URL}/config.json`, + 'generation_config.json': `${BASE_MODEL_URL}/generation_config.json`, + 'tokenizer.json': `${BASE_MODEL_URL}/tokenizer.json`, + 'tokenizer_config.json': `${BASE_MODEL_URL}/tokenizer_config.json`, + 'merges.txt': `${BASE_MODEL_URL}/merges.txt`, + 'vocab.json': `${BASE_MODEL_URL}/vocab.json`, + 'normalizer.json': `${BASE_MODEL_URL}/normalizer.json`, + 'added_tokens.json': `${BASE_MODEL_URL}/added_tokens.json`, + 'preprocessor_config.json': `${BASE_MODEL_URL}/preprocessor_config.json`, + 'special_tokens_map.json': `${BASE_MODEL_URL}/special_tokens_map.json`, + 'onnx/encoder_model_int8.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_int8.onnx`, + 'onnx/decoder_model_merged_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_int8.onnx`, + 'onnx/decoder_with_past_model_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_int8.onnx`, +}; + +const ENV_URL_OVERRIDES: Record = { + 'config.json': 'OPENREADER_WHISPER_MODEL_CONFIG_URL', + 'generation_config.json': 'OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL', + 'tokenizer.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_URL', + 'tokenizer_config.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL', + 'merges.txt': 'OPENREADER_WHISPER_MODEL_MERGES_URL', + 'vocab.json': 'OPENREADER_WHISPER_MODEL_VOCAB_URL', + 'normalizer.json': 'OPENREADER_WHISPER_MODEL_NORMALIZER_URL', + 'added_tokens.json': 'OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL', + 'preprocessor_config.json': 'OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL', + 'special_tokens_map.json': 'OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL', + 'onnx/encoder_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_ENCODER_URL', + 'onnx/decoder_model_merged_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL', + 'onnx/decoder_with_past_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL', +}; + +type ManifestEntry = { path: string; sha256?: string; size?: number }; + +export interface WhisperArtifactSpec { + path: string; + sha256?: string; + size?: number; + url: string; +} + +export interface WhisperStaticArtifactSpec { + path: string; + sha256?: string; + size?: number; + sourcePath: string; +} + +export type WhisperFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +const MANIFEST_FILES = manifest.files as ManifestEntry[]; +const MODEL_FILES = MANIFEST_FILES.filter((entry) => entry.path !== 'LICENSE.txt'); +const LICENSE_FILE = MANIFEST_FILES.find((entry) => entry.path === 'LICENSE.txt'); + +function normalizeExpected(entry: { sha256?: string; size?: number }): { sha256: string | null; size: number } { + return { + sha256: typeof entry.sha256 === 'string' ? entry.sha256.toLowerCase() : null, + size: Number(entry.size ?? 0), + }; +} + +function resolvePath(relativePath: string, modelDir: string): string { + return path.join(modelDir, relativePath); +} + +function resolveUrl(relativePath: string): string { + const envKey = ENV_URL_OVERRIDES[relativePath]; + const override = envKey ? process.env[envKey]?.trim() : ''; + if (override) return override; + const fallback = DEFAULT_URLS[relativePath]; + if (!fallback) { + throw new Error(`No default URL configured for Whisper model artifact: ${relativePath}`); + } + return fallback; +} + +function sha256OfBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function verifyBytes(bytes: Uint8Array, expected: { sha256?: string; size?: number }): boolean { + const normalized = normalizeExpected(expected); + if (Number.isFinite(normalized.size) && normalized.size > 0 && bytes.byteLength !== normalized.size) { + return false; + } + if (!normalized.sha256) return true; + return sha256OfBytes(bytes) === normalized.sha256; +} + +async function verifyFile(filePath: string, expected: { sha256?: string; size?: number }): Promise { + const bytes = await readFile(filePath); + return verifyBytes(bytes, expected); +} + +async function downloadToFile(fetchImpl: WhisperFetch, url: string, outPath: string): Promise { + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +export async function ensureWhisperArtifacts(options: { + modelDir: string; + artifacts: WhisperArtifactSpec[]; + staticArtifacts?: WhisperStaticArtifactSpec[]; + fetchImpl?: WhisperFetch; +}): Promise { + const { + modelDir, + artifacts, + staticArtifacts = [], + fetchImpl = fetch, + } = options; + + try { + await Promise.all(artifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper artifact: ${artifact.path}`); + } + })); + + await Promise.all(staticArtifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper static artifact: ${artifact.path}`); + } + })); + + return; + } catch { + // Continue to repair/download. + } + + for (const artifact of artifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + const tmp = `${target}.tmp`; + + await mkdir(targetDir, { recursive: true }); + await downloadToFile(fetchImpl, artifact.url, tmp); + if (!(await verifyFile(tmp, artifact))) { + await unlink(tmp).catch(() => undefined); + throw new Error(`Whisper artifact checksum verification failed: ${artifact.path}`); + } + await rename(tmp, target); + } + + for (const artifact of staticArtifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + await mkdir(targetDir, { recursive: true }); + await copyFile(artifact.sourcePath, target); + if (!(await verifyFile(target, artifact))) { + throw new Error(`Whisper static artifact checksum verification failed: ${artifact.path}`); + } + } +} + +export function createSingleflightRunner(work: () => Promise): () => Promise { + let inflight: Promise | null = null; + return async () => { + if (inflight) return inflight; + inflight = work().finally(() => { + inflight = null; + }); + return inflight; + }; +} + +async function ensureModelInternal(): Promise { + const artifacts: WhisperArtifactSpec[] = MODEL_FILES.map((entry) => ({ + path: entry.path, + sha256: entry.sha256, + size: entry.size, + url: resolveUrl(entry.path), + })); + + const staticArtifacts: WhisperStaticArtifactSpec[] = LICENSE_FILE + ? [{ + path: LICENSE_FILE.path, + sha256: LICENSE_FILE.sha256, + size: LICENSE_FILE.size, + sourcePath: STATIC_LICENSE_PATH, + }] + : []; + + await ensureWhisperArtifacts({ + modelDir: MODEL_DIR, + artifacts, + staticArtifacts, + }); + + return WHISPER_ENCODER_MODEL_PATH; +} + +const ensureWhisperModelSingleflight = createSingleflightRunner(ensureModelInternal); + +export async function ensureWhisperModel(): Promise { + return ensureWhisperModelSingleflight(); +} diff --git a/src/lib/server/whisper/model/LICENSE.txt b/src/lib/server/whisper/model/LICENSE.txt new file mode 100644 index 0000000..d255525 --- /dev/null +++ b/src/lib/server/whisper/model/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib/server/whisper/model/manifest.json b/src/lib/server/whisper/model/manifest.json new file mode 100644 index 0000000..2fffd3d --- /dev/null +++ b/src/lib/server/whisper/model/manifest.json @@ -0,0 +1,76 @@ +{ + "name": "whisper-base_timestamped-int8", + "version": "onnx-community/whisper-base_timestamped@608c49e61301901684bc36cac8f74b95ff6b5a8e", + "files": [ + { + "path": "config.json", + "sha256": "f4d0608f7d918166da7edb3e188de5ef1bfe70d9802e785d271fd88111e9cf4b", + "size": 2243 + }, + { + "path": "generation_config.json", + "sha256": "61070cf8de25b1e9256e8e102ded49d8d24a8369ed36ef84fdf21549e68125a0", + "size": 3832 + }, + { + "path": "tokenizer.json", + "sha256": "27fc476bfe7f17299480be2273fc0608e4d5a99aba2ab5dec5374b4482d1a566", + "size": 2480466 + }, + { + "path": "tokenizer_config.json", + "sha256": "2e036e4dbacfdeb7242c7d4ec4149f4a16e86026048f94d1637e3a8ee9c6a573", + "size": 282682 + }, + { + "path": "merges.txt", + "sha256": "2df2990a395e35e8dfbc7511e08c12d56018d8d04691e0133e5d63b21e154dc6", + "size": 493869 + }, + { + "path": "vocab.json", + "sha256": "50d6a919f0a0601d56a04eb583c780d18553aa388254ba3158eb6a00f13e2c1a", + "size": 1036584 + }, + { + "path": "normalizer.json", + "sha256": "bf1c507dc8724ca9cf9903640dacfb69dae2f00edee4f21ceba106a7392f26dd", + "size": 52666 + }, + { + "path": "added_tokens.json", + "sha256": "9715fd2243b6f06a5858b5e32950d2853f73dd5bc201aafcf76f5082a2d8acd1", + "size": 34604 + }, + { + "path": "preprocessor_config.json", + "sha256": "a6a76d28c93edb273669eb9e0b0636a2bddbb1272c3261e47b7ca6dfdbac1b8d", + "size": 339 + }, + { + "path": "special_tokens_map.json", + "sha256": "e67ae3a0aaa99abcd9f187138e12db1f65c16a14761c50ef10eef2c174a7a691", + "size": 2194 + }, + { + "path": "onnx/encoder_model_int8.onnx", + "sha256": "152da96dd8ff3f28f3fadabc2e8960405a277846453ff94ed411fe935a72917f", + "size": 23159150 + }, + { + "path": "onnx/decoder_model_merged_int8.onnx", + "sha256": "cf9a8d5bcddc0917a0078135b484cedcaf44f28909cd91910abd29dced9171db", + "size": 53712708 + }, + { + "path": "onnx/decoder_with_past_model_int8.onnx", + "sha256": "bdd92860d0ed7dff2aca623963378cbba1b617bfae127356db1c8aa8baa930ef", + "size": 50131672 + }, + { + "path": "LICENSE.txt", + "sha256": "b5d65a59060e68c4ff940e1eddfa6f94b2d68fdf58ed7f4dd57721c997e35e9d", + "size": 1063 + } + ] +} diff --git a/src/lib/server/whisper/model/mel_filters.npz b/src/lib/server/whisper/model/mel_filters.npz new file mode 100644 index 0000000000000000000000000000000000000000..28ea26909dbdfd608aef67afc4d74d7961ae4bb6 GIT binary patch literal 4271 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 AQvd(} literal 0 HcmV?d00001 diff --git a/src/lib/server/whisper/spectral.ts b/src/lib/server/whisper/spectral.ts new file mode 100644 index 0000000..b7223cc --- /dev/null +++ b/src/lib/server/whisper/spectral.ts @@ -0,0 +1,21 @@ +export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array { + const coeffs = new Float64Array(freqBins); + for (let k = 0; k < freqBins; k += 1) { + coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize); + } + return coeffs; +} + +export function goertzelPower(samples: Float32Array, coeff: number): number { + let s1 = 0; + let s2 = 0; + for (let i = 0; i < samples.length; i += 1) { + const s0 = samples[i] + (coeff * s1) - s2; + s2 = s1; + s1 = s0; + } + + const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2); + if (!Number.isFinite(power) || power < 0) return 0; + return power; +} diff --git a/src/lib/server/whisper/token-timestamps.ts b/src/lib/server/whisper/token-timestamps.ts new file mode 100644 index 0000000..47edc1d --- /dev/null +++ b/src/lib/server/whisper/token-timestamps.ts @@ -0,0 +1,449 @@ +import type { Tokenizer } from '@huggingface/tokenizers'; +import type * as ort from 'onnxruntime-node'; + +const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E'; +const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu'); + +type TokenTimestamp = [start: number, end: number]; + +export interface WhisperWordTiming { + word: string; + startSec: number; + endSec: number; +} + +function medianFilter(data: Float32Array, windowSize: number): Float32Array { + if (windowSize % 2 === 0 || windowSize <= 0) { + throw new Error('Window size must be a positive odd number'); + } + + const output = new Float32Array(data.length); + const buffer = new Float32Array(windowSize); + const halfWindow = Math.floor(windowSize / 2); + + for (let i = 0; i < data.length; i += 1) { + let valuesIndex = 0; + for (let j = -halfWindow; j <= halfWindow; j += 1) { + let index = i + j; + if (index < 0) { + index = Math.abs(index); + } else if (index >= data.length) { + index = (2 * (data.length - 1)) - index; + } + buffer[valuesIndex] = data[index]; + valuesIndex += 1; + } + + const sortable = Array.from(buffer); + sortable.sort((a, b) => a - b); + output[i] = sortable[halfWindow] ?? 0; + } + + return output; +} + +function dynamicTimeWarping(matrix: Float32Array[], rows: number, cols: number): [number[], number[]] { + const cost: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(Number.POSITIVE_INFINITY)); + const trace: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(-1)); + cost[0][0] = 0; + + for (let j = 1; j <= cols; j += 1) { + for (let i = 1; i <= rows; i += 1) { + const c0 = cost[i - 1][j - 1]; + const c1 = cost[i - 1][j]; + const c2 = cost[i][j - 1]; + let c: number; + let t: number; + if (c0 < c1 && c0 < c2) { + c = c0; + t = 0; + } else if (c1 < c0 && c1 < c2) { + c = c1; + t = 1; + } else { + c = c2; + t = 2; + } + cost[i][j] = matrix[i - 1][j - 1] + c; + trace[i][j] = t; + } + } + + for (let i = 0; i <= cols; i += 1) trace[0][i] = 2; + for (let i = 0; i <= rows; i += 1) trace[i][0] = 1; + + let i = rows; + let j = cols; + const textIndices: number[] = []; + const timeIndices: number[] = []; + while (i > 0 || j > 0) { + textIndices.push(i - 1); + timeIndices.push(j - 1); + const step = trace[i][j]; + if (step === 0) { + i -= 1; + j -= 1; + } else if (step === 1) { + i -= 1; + } else if (step === 2) { + j -= 1; + } else { + throw new Error(`Unexpected DTW trace state at [${i}, ${j}]`); + } + } + + textIndices.reverse(); + timeIndices.reverse(); + return [textIndices, timeIndices]; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function decodeTokens(tokenizer: Pick, tokens: number[]): string { + return tokenizer.decode(tokens, { skip_special_tokens: false }); +} + +function splitTokensOnUnicode( + tokenizer: Pick, + tokens: number[], +): [string[], number[][], number[][]] { + const decodedFull = decodeTokens(tokenizer, tokens); + const replacementChar = '\uFFFD'; + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + let currentTokens: number[] = []; + let currentIndices: number[] = []; + let unicodeOffset = 0; + + for (let i = 0; i < tokens.length; i += 1) { + currentTokens.push(tokens[i]); + currentIndices.push(i); + + const decoded = decodeTokens(tokenizer, currentTokens); + if ( + !decoded.includes(replacementChar) + || decodedFull[unicodeOffset + decoded.indexOf(replacementChar)] === replacementChar + ) { + words.push(decoded); + wordTokens.push(currentTokens); + tokenIndices.push(currentIndices); + currentTokens = []; + currentIndices = []; + unicodeOffset += decoded.length; + } + } + + return [words, wordTokens, tokenIndices]; +} + +function splitTokensOnSpaces( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, +): [string[], number[][], number[][]] { + const [subwords, subwordTokens, subwordIndices] = splitTokensOnUnicode(tokenizer, tokens); + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + + for (let i = 0; i < subwords.length; i += 1) { + const subword = subwords[i]; + const tokenList = subwordTokens[i]; + const indices = subwordIndices[i]; + const special = tokenList[0] >= eosTokenId; + const withSpace = subword.startsWith(' '); + const trimmed = subword.trim(); + const punctuation = PUNCTUATION_ONLY_REGEX.test(trimmed); + + if (special || withSpace || punctuation || words.length === 0) { + words.push(subword); + wordTokens.push([...tokenList]); + tokenIndices.push([...indices]); + } else { + const ix = words.length - 1; + words[ix] += subword; + wordTokens[ix].push(...tokenList); + tokenIndices[ix].push(...indices); + } + } + + return [words, wordTokens, tokenIndices]; +} + +function mergePunctuations( + words: string[], + tokens: number[][], + indices: number[][], + prependPunctuations = '"\'“¡¿([{-', + appendPunctuations = '"\'.。,,!!??::”)]}、', +): [string[], number[][], number[][]] { + const newWords = words.map((w) => `${w}`); + const newTokens = tokens.map((t) => [...t]); + const newIndices = indices.map((idx) => [...idx]); + + let i = newWords.length - 2; + let j = newWords.length - 1; + while (i >= 0) { + if (newWords[i].startsWith(' ') && prependPunctuations.includes(newWords[i].trim())) { + newWords[j] = newWords[i] + newWords[j]; + newTokens[j] = [...newTokens[i], ...newTokens[j]]; + newIndices[j] = [...newIndices[i], ...newIndices[j]]; + newWords[i] = ''; + newTokens[i] = []; + newIndices[i] = []; + } else { + j = i; + } + i -= 1; + } + + i = 0; + j = 1; + while (j < newWords.length) { + if (!newWords[i].endsWith(' ') && appendPunctuations.includes(newWords[j])) { + newWords[i] += newWords[j]; + newTokens[i] = [...newTokens[i], ...newTokens[j]]; + newIndices[i] = [...newIndices[i], ...newIndices[j]]; + newWords[j] = ''; + newTokens[j] = []; + newIndices[j] = []; + } else { + i = j; + } + j += 1; + } + + return [ + newWords.filter((w) => w.length > 0), + newTokens.filter((t) => t.length > 0), + newIndices.filter((t) => t.length > 0), + ]; +} + +function combineTokensIntoWords( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, + language = 'english', +): [string[], number[][], number[][]] { + let words: string[]; + let wordTokens: number[][]; + let tokenIndices: number[][]; + + if (['chinese', 'japanese', 'thai', 'lao', 'myanmar', 'zh', 'ja', 'th', 'lo', 'my'].includes(language)) { + [words, wordTokens, tokenIndices] = splitTokensOnUnicode(tokenizer, tokens); + } else { + [words, wordTokens, tokenIndices] = splitTokensOnSpaces(tokenizer, tokens, eosTokenId); + } + + return mergePunctuations(words, wordTokens, tokenIndices); +} + +export function extractTokenStartTimestamps(input: { + crossAttentions: Record; + decoderLayers: number; + alignmentHeads: Array<[number, number]>; + numFrames: number; + numInputIds: number; + timePrecision?: number; + sequenceLength: number; +}): number[] { + const { + crossAttentions, + decoderLayers, + alignmentHeads, + numFrames, + numInputIds, + timePrecision = 0.02, + sequenceLength, + } = input; + + const frameCount = Math.max(1, numFrames); + const perLayer: Float32Array[] = []; + for (let layer = 0; layer < decoderLayers; layer += 1) { + const key = `cross_attentions.${layer}`; + const tensor = crossAttentions[key]; + if (!tensor) continue; + perLayer[layer] = tensor.data as Float32Array; + } + + const selected: Float32Array[] = []; + let seqLen = 0; + let attnFrames = 0; + for (const [layer, head] of alignmentHeads) { + const flat = perLayer[layer]; + if (!flat) continue; + const layerTensor = crossAttentions[`cross_attentions.${layer}`]; + if (!layerTensor || layerTensor.dims.length < 4) continue; + const [, numHeads, currentSeqLen, currentFrames] = layerTensor.dims; + if (head >= numHeads) continue; + seqLen = currentSeqLen; + attnFrames = Math.min(currentFrames, frameCount); + const headSlice = new Float32Array(seqLen * attnFrames); + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < attnFrames; f += 1) { + const flatIndex = (((head * currentSeqLen) + s) * currentFrames) + f; + headSlice[(s * attnFrames) + f] = flat[flatIndex] ?? 0; + } + } + selected.push(headSlice); + } + + if (!selected.length || seqLen === 0 || attnFrames === 0) { + return new Array(sequenceLength).fill(0); + } + + const normalizedHeads = selected.map((headData) => { + const means = new Float32Array(attnFrames); + const stds = new Float32Array(attnFrames); + + for (let f = 0; f < attnFrames; f += 1) { + let sum = 0; + for (let s = 0; s < seqLen; s += 1) sum += headData[(s * attnFrames) + f]; + const mean = sum / seqLen; + means[f] = mean; + let varSum = 0; + for (let s = 0; s < seqLen; s += 1) { + const d = headData[(s * attnFrames) + f] - mean; + varSum += d * d; + } + stds[f] = Math.sqrt(varSum / seqLen) || 1; + } + + const out = new Float32Array(headData.length); + for (let s = 0; s < seqLen; s += 1) { + const row = new Float32Array(attnFrames); + for (let f = 0; f < attnFrames; f += 1) { + row[f] = (headData[(s * attnFrames) + f] - means[f]) / stds[f]; + } + const filtered = medianFilter(row, 7); + out.set(filtered, s * attnFrames); + } + return out; + }); + + const croppedRows = Math.max(0, seqLen - numInputIds); + if (croppedRows === 0) return new Array(sequenceLength).fill(0); + + const matrix: Float32Array[] = Array.from({ length: croppedRows }, () => new Float32Array(attnFrames)); + for (const headData of normalizedHeads) { + for (let r = 0; r < croppedRows; r += 1) { + const srcRow = r + numInputIds; + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] += headData[(srcRow * attnFrames) + f]; + } + } + } + + const scale = 1 / normalizedHeads.length; + for (let r = 0; r < croppedRows; r += 1) { + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] = -matrix[r][f] * scale; + } + } + + const [textIndices, timeIndices] = dynamicTimeWarping(matrix, croppedRows, attnFrames); + const jumps = new Array(textIndices.length).fill(false); + for (let i = 0; i < textIndices.length; i += 1) { + jumps[i] = i === 0 ? true : textIndices[i] !== textIndices[i - 1]; + } + + const jumpTimes: number[] = []; + for (let i = 0; i < jumps.length; i += 1) { + if (jumps[i]) jumpTimes.push(timeIndices[i] * timePrecision); + } + + const timestamps = new Array(sequenceLength).fill(0); + for (let i = 0; i < numInputIds && i < timestamps.length; i += 1) timestamps[i] = 0; + for (let i = 0; i < jumpTimes.length && (numInputIds + i) < timestamps.length; i += 1) { + timestamps[numInputIds + i] = jumpTimes[i]; + } + if (timestamps.length > 0 && jumpTimes.length > 0) { + timestamps[timestamps.length - 1] = jumpTimes[jumpTimes.length - 1]; + } + return timestamps; +} + +export function buildWordsFromTimestampedTokens(input: { + tokens: number[]; + tokenStartTimestamps: number[]; + tokenizer: Pick; + eosTokenId: number; + promptLength: number; + timestampBeginTokenId: number; + timePrecision?: number; + language?: string; +}): WhisperWordTiming[] { + const { + tokens, + tokenStartTimestamps, + tokenizer, + eosTokenId, + promptLength, + timestampBeginTokenId, + timePrecision = 0.02, + language = 'english', + } = input; + + const limit = Math.min(tokens.length, tokenStartTimestamps.length); + const tokenRanges: TokenTimestamp[] = []; + for (let i = 0; i < limit; i += 1) { + const start = tokenStartTimestamps[i] ?? 0; + const end = i + 1 < limit ? (tokenStartTimestamps[i + 1] ?? (start + timePrecision)) : (start + timePrecision); + tokenRanges.push([start, Math.max(start, end)]); + } + + const words: WhisperWordTiming[] = []; + let segmentStart: number | null = null; + let textTokens: number[] = []; + let textRanges: TokenTimestamp[] = []; + + const flushSegment = (segmentEnd: number | null) => { + if (!textTokens.length) return; + const [wordTexts, , tokenIndices] = combineTokensIntoWords(tokenizer, textTokens, eosTokenId, language); + for (let i = 0; i < wordTexts.length; i += 1) { + const indices = tokenIndices[i]; + if (!indices.length) continue; + const start = textRanges[indices[0]]?.[0] ?? segmentStart ?? 0; + const end = textRanges[indices[indices.length - 1]]?.[1] ?? segmentEnd ?? start; + const clampedStart = segmentStart == null ? start : Math.max(segmentStart, start); + const clampedEndBase = segmentEnd == null ? end : Math.min(segmentEnd, end); + const clampedEnd = Math.max( + clampedStart + (clampedEndBase <= clampedStart ? timePrecision : 0), + clampedEndBase, + ); + words.push({ + word: wordTexts[i].trim(), + startSec: round2(clampedStart), + endSec: round2(clampedEnd), + }); + } + textTokens = []; + textRanges = []; + }; + + for (let i = promptLength; i < limit; i += 1) { + const token = tokens[i]; + if (token === eosTokenId) break; + + if (token >= timestampBeginTokenId) { + const ts = (token - timestampBeginTokenId) * timePrecision; + if (segmentStart == null) { + segmentStart = ts; + } else { + flushSegment(ts); + segmentStart = ts; + } + continue; + } + + textTokens.push(token); + textRanges.push(tokenRanges[i]); + } + + flushSegment(null); + return words.filter((w) => w.word.length > 0); +} diff --git a/src/types/client.ts b/src/types/client.ts index aed3042..0b94363 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -1,8 +1,7 @@ import type { TTSAudiobookChapter, - TTSSentenceAlignment, - TTSAudioBytes, TTSAudiobookFormat, + TTSSentenceAlignment, } from '@/types/tts'; import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog'; @@ -64,17 +63,6 @@ export interface VoicesResponse { voices: string[]; } -// --- Whisper API Types --- - -export interface AlignmentPayload { - text: string; - audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer)) -} - -export interface AlignmentResponse { - alignments: TTSSentenceAlignment[]; -} - export interface TTSSegmentSettings { providerRef: string; providerType: TtsProviderType; diff --git a/tests/unit/whisper-alignment-mapping.spec.ts b/tests/unit/whisper-alignment-mapping.spec.ts new file mode 100644 index 0000000..aba2933 --- /dev/null +++ b/tests/unit/whisper-alignment-mapping.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; +import { + mapWordsToSentenceOffsets, +} from '../../src/lib/server/whisper/alignment-mapping'; + +test.describe('whisper alignment mapping', () => { + test('maps words to sentence offsets with punctuation and repeated spaces', () => { + const aligned = mapWordsToSentenceOffsets('Hello, world again.', [ + { word: 'Hello', start: 0, end: 0.25 }, + { word: 'world', start: 0.25, end: 0.5 }, + { word: 'again', start: 0.5, end: 1.0 }, + ]); + + expect(aligned.words).toHaveLength(3); + expect(aligned.words[0].charStart).toBe(0); + expect(aligned.words[0].charEnd).toBe(5); + expect(aligned.words[1].charStart).toBeGreaterThan(aligned.words[0].charEnd); + expect(aligned.words[2].charStart).toBeGreaterThan(aligned.words[1].charEnd); + expect(aligned.words[2].charEnd).toBeLessThanOrEqual('Hello, world again.'.length); + }); +}); diff --git a/tests/unit/whisper-alignment-smoke.spec.ts b/tests/unit/whisper-alignment-smoke.spec.ts new file mode 100644 index 0000000..2693dc9 --- /dev/null +++ b/tests/unit/whisper-alignment-smoke.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import { alignAudioWithText } from '../../src/lib/server/whisper/alignment'; + +test.describe('whisper alignment smoke', () => { + test('runs ONNX alignment end-to-end without decoder reshape errors', async () => { + test.setTimeout(180000); + + const audioPath = path.join(process.cwd(), 'tests/files/sample.mp3'); + const audioBytes = await readFile(audioPath); + const buffer = audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength); + + const alignments = await alignAudioWithText( + buffer, + 'This is a sample sentence used to validate whisper alignment execution.', + undefined, + { lang: 'en' }, + ); + + expect(alignments.length).toBe(1); + expect(Array.isArray(alignments[0].words)).toBe(true); + expect(alignments[0].words.length).toBeGreaterThan(0); + + let maxEnd = 0; + let positiveDurationWordCount = 0; + for (const word of alignments[0].words) { + expect(Number.isFinite(word.startSec)).toBe(true); + expect(Number.isFinite(word.endSec)).toBe(true); + expect(word.endSec).toBeGreaterThanOrEqual(word.startSec); + maxEnd = Math.max(maxEnd, word.endSec); + if (word.endSec > word.startSec) positiveDurationWordCount += 1; + } + expect(maxEnd).toBeLessThanOrEqual(10.2); + expect(positiveDurationWordCount).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/whisper-ensure-model.spec.ts b/tests/unit/whisper-ensure-model.spec.ts new file mode 100644 index 0000000..166f022 --- /dev/null +++ b/tests/unit/whisper-ensure-model.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; +import { createHash } from 'crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { + createSingleflightRunner, + ensureWhisperArtifacts, +} from '../../src/lib/server/whisper/ensureModel'; + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +test.describe('whisper ensure model helpers', () => { + test('downloads and verifies artifacts, and repairs checksum mismatch', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'openreader-whisper-model-test-')); + const artifactBytes = new TextEncoder().encode('artifact-content-v1'); + const artifactHash = sha256(artifactBytes); + const artifactPath = 'onnx/encoder_model_int8.onnx'; + const target = path.join(root, artifactPath); + + try { + // Seed a corrupted file to verify repair behavior. + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, new Uint8Array([0, 1, 2, 3])); + + let fetchCount = 0; + await ensureWhisperArtifacts({ + modelDir: root, + artifacts: [ + { + path: artifactPath, + sha256: artifactHash, + size: artifactBytes.byteLength, + url: 'https://example.local/fake-artifact', + }, + ], + fetchImpl: async () => { + fetchCount += 1; + return new Response(artifactBytes, { status: 200 }); + }, + }); + + const repaired = await readFile(target); + expect(repaired.byteLength).toBe(artifactBytes.byteLength); + expect(sha256(repaired)).toBe(artifactHash); + expect(fetchCount).toBe(1); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('singleflight runner deduplicates concurrent work', async () => { + let runs = 0; + const run = createSingleflightRunner(async () => { + runs += 1; + await new Promise((resolve) => setTimeout(resolve, 30)); + return 'ok'; + }); + + const [a, b, c] = await Promise.all([run(), run(), run()]); + expect(a).toBe('ok'); + expect(b).toBe('ok'); + expect(c).toBe('ok'); + expect(runs).toBe(1); + + await run(); + expect(runs).toBe(2); + }); +}); diff --git a/tests/unit/whisper-spectral.spec.ts b/tests/unit/whisper-spectral.spec.ts new file mode 100644 index 0000000..3358532 --- /dev/null +++ b/tests/unit/whisper-spectral.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { buildGoertzelCoefficients, goertzelPower } from '../../src/lib/server/whisper/spectral'; + +function dftPower(samples: Float32Array, k: number): number { + const n = samples.length; + let re = 0; + let im = 0; + for (let i = 0; i < n; i += 1) { + const angle = (-2 * Math.PI * k * i) / n; + re += samples[i] * Math.cos(angle); + im += samples[i] * Math.sin(angle); + } + return (re * re) + (im * im); +} + +test.describe('whisper spectral helpers', () => { + test('goertzel power matches direct DFT for non-power-of-two frame size', () => { + const frameSize = 400; + const bins = 201; + const coeffs = buildGoertzelCoefficients(bins, frameSize); + const samples = new Float32Array(frameSize); + for (let i = 0; i < frameSize; i += 1) { + samples[i] = Math.sin((2 * Math.PI * 37 * i) / frameSize) + (0.2 * Math.cos((2 * Math.PI * 91 * i) / frameSize)); + } + + const testBins = [0, 7, 37, 91, 150, 200]; + for (const k of testBins) { + const expected = dftPower(samples, k); + const actual = goertzelPower(samples, coeffs[k]); + const rel = Math.abs(actual - expected) / Math.max(1, Math.abs(expected)); + expect(rel).toBeLessThan(1e-5); + } + }); +}); diff --git a/tests/unit/whisper-token-timestamps.spec.ts b/tests/unit/whisper-token-timestamps.spec.ts new file mode 100644 index 0000000..22ebebd --- /dev/null +++ b/tests/unit/whisper-token-timestamps.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +import * as ort from 'onnxruntime-node'; +import { + buildWordsFromTimestampedTokens, + extractTokenStartTimestamps, +} from '../../src/lib/server/whisper/token-timestamps'; + +test.describe('whisper token timestamp alignment', () => { + test('extracts monotonic token timestamps from cross-attention maps', () => { + const seqLen = 6; + const frames = 10; + const heads = 8; + const data = new Float32Array(1 * heads * seqLen * frames); + for (let s = 0; s < seqLen; s += 1) { + const peak = Math.min(frames - 1, s + 1); + for (let f = 0; f < frames; f += 1) { + const val = -Math.abs(f - peak); + const idx = (((0 * seqLen) + s) * frames) + f; + data[idx] = val; + } + } + + const cross = { + 'cross_attentions.0': new ort.Tensor('float32', data, [1, heads, seqLen, frames]), + }; + + const ts = extractTokenStartTimestamps({ + crossAttentions: cross, + decoderLayers: 6, + alignmentHeads: [[0, 0]], + numFrames: frames, + numInputIds: 3, + sequenceLength: seqLen, + timePrecision: 0.02, + }); + + expect(ts).toHaveLength(seqLen); + expect(ts[0]).toBe(0); + expect(ts[1]).toBe(0); + expect(ts[2]).toBe(0); + expect(ts[3]).toBeGreaterThanOrEqual(0); + expect(ts[4]).toBeGreaterThanOrEqual(ts[3]); + expect(ts[5]).toBeGreaterThanOrEqual(ts[4]); + }); + + test('builds word timings from token timestamps with punctuation merge', () => { + const tokenText: Record = { + 100: ' hello', + 101: ' world', + 102: '!', + }; + const tokenizer = { + decode(tokens: number[]) { + return tokens.map((t) => tokenText[t] ?? '').join(''); + }, + }; + + const timestampBeginTokenId = 50364; + const tokens = [ + 1, 2, 3, + timestampBeginTokenId, + 100, 101, 102, + timestampBeginTokenId + 50, + ]; + const starts = [0, 0, 0, 0, 0.1, 0.3, 0.5, 1.0]; + + const words = buildWordsFromTimestampedTokens({ + tokens, + tokenStartTimestamps: starts, + tokenizer, + eosTokenId: 50257, + promptLength: 3, + timestampBeginTokenId, + timePrecision: 0.02, + language: 'en', + }); + + expect(words.length).toBe(2); + expect(words[0].word.toLowerCase()).toContain('hello'); + expect(words[1].word.toLowerCase()).toContain('world'); + expect(words[1].word).toContain('!'); + expect(words[0].startSec).toBeGreaterThanOrEqual(0); + expect(words[1].endSec).toBeGreaterThanOrEqual(words[1].startSec); + }); +}); From 3a21f2a5f56268e806f25b6b22a0d4424e8316e9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 13:27:07 -0600 Subject: [PATCH 008/137] refactor(config): migrate compute and runtime env vars to new naming scheme - Replace `OPENREADER_COMPUTE_MODE` and related variables with `COMPUTE_MODE` - Replace `OPENREADER_*` PDF/Whisper model URLs with `PDF_LAYOUT_MODEL_BASE_URL` and `WHISPER_MODEL_BASE_URL` - Remove legacy `NEXT_PUBLIC_*` runtime config seeds in favor of `RUNTIME_SEED_*` - Update documentation, code, and environment references to match new variable names - Remove deprecated `scripts/fetch-models.mjs` and related npm script - Update runtime config SSR injection from `window.__OPENREADER_RUNTIME_CONFIG__` to `window.__RUNTIME_CONFIG__` - Adjust Next.js config to use new compute mode env var and optimize output file tracing for ONNX dependencies BREAKING CHANGE: Environment variable names for compute mode, model URLs, and runtime config seeding have changed. Update `.env` files and deployment configs to use `COMPUTE_MODE`, `PDF_LAYOUT_MODEL_BASE_URL`, `WHISPER_MODEL_BASE_URL`, and `RUNTIME_SEED_*` as appropriate. Legacy `OPENREADER_*` and `NEXT_PUBLIC_*` variables are no longer supported. --- .env.example | 48 +++----- README.md | 2 +- docs-site/docs/configure/admin-panel.md | 10 +- docs-site/docs/configure/auth.md | 2 +- docs-site/docs/configure/tts-providers.md | 2 +- docs-site/docs/deploy/local-development.md | 20 ++-- docs-site/docs/deploy/vercel-deployment.md | 6 +- docs-site/docs/docker-quick-start.md | 2 +- .../docs/reference/environment-variables.md | 104 ++++++------------ .../reference/environment-variables.md | 2 +- next.config.ts | 28 +++-- package.json | 1 - scripts/fetch-models.mjs | 51 --------- src/app/layout.tsx | 2 +- src/contexts/RuntimeConfigContext.tsx | 8 +- src/contexts/TTSContext.tsx | 2 +- src/lib/server/admin/seed.ts | 2 +- src/lib/server/admin/settings.ts | 18 +-- src/lib/server/compute/index.ts | 7 +- src/lib/server/compute/mode.ts | 4 +- src/lib/server/compute/none.ts | 4 +- src/lib/server/pdf-layout/ensureModel.ts | 24 ++-- src/lib/server/whisper/ensureModel.ts | 52 +++++---- src/types/config.ts | 6 +- 24 files changed, 166 insertions(+), 241 deletions(-) delete mode 100644 scripts/fetch-models.mjs diff --git a/.env.example b/.env.example index 03d69a5..3b38e23 100644 --- a/.env.example +++ b/.env.example @@ -81,31 +81,15 @@ IMPORT_LIBRARY_DIRS= # local = run compute in-process (default) # none = disable both capabilities (good for preview/serverless) # worker = reserved for future external worker mode (not implemented in v1) -OPENREADER_COMPUTE_MODE=local -# OPENREADER_COMPUTE_WORKER_URL= -# OPENREADER_COMPUTE_WORKER_TOKEN= +COMPUTE_MODE=local +# COMPUTE_WORKER_URL= +# COMPUTE_WORKER_TOKEN= -# Optional overrides for Whisper ONNX artifacts -# Defaults target: onnx-community/whisper-base_timestamped int8 -# OPENREADER_WHISPER_MODEL_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/config.json -# OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/generation_config.json -# OPENREADER_WHISPER_MODEL_TOKENIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer.json -# OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/tokenizer_config.json -# OPENREADER_WHISPER_MODEL_MERGES_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/merges.txt -# OPENREADER_WHISPER_MODEL_VOCAB_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/vocab.json -# OPENREADER_WHISPER_MODEL_NORMALIZER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/normalizer.json -# OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/added_tokens.json -# OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/preprocessor_config.json -# OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/special_tokens_map.json -# OPENREADER_WHISPER_MODEL_ENCODER_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/encoder_model_int8.onnx -# OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_model_merged_int8.onnx -# OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main/onnx/decoder_with_past_model_int8.onnx +# Optional Whisper ONNX base URL override (must contain all expected files) +# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main -# Optional overrides for PDF layout model artifacts -# OPENREADER_PDF_LAYOUT_MODEL_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx -# OPENREADER_PDF_LAYOUT_MODEL_DATA_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data -# OPENREADER_PDF_LAYOUT_CONFIG_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json -# OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json +# Optional PDF layout ONNX base URL override (must contain all expected files) +# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main # (Optional) Override ffmpeg binary path used for audiobook processing FFMPEG_BIN= @@ -113,12 +97,12 @@ FFMPEG_BIN= # (Optional) Client feature flags — seeded into the admin-managed runtime # config on first boot, then ignored. Edit values from Settings → Admin → # Site features instead of redeploying. SSR-injected so they take effect -# without rebuilding (unlike the old NEXT_PUBLIC_* build-time pattern). -# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true -# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true -# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true -# NEXT_PUBLIC_ENABLE_USER_SIGNUPS=true -# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true -# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai -# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json -# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true +# without rebuilding (unlike the old build-time public-env pattern). +# RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true +# RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true +# RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true +# RUNTIME_SEED_ENABLE_USER_SIGNUPS=true +# RUNTIME_SEED_RESTRICT_USER_API_KEYS=true +# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai +# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json +# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true diff --git a/README.md b/README.md index 63a99ec..73dfdc9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment in local compute mode (`OPENREADER_COMPUTE_MODE=local`). +- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment in local compute mode (`COMPUTE_MODE=local`). - 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index e00d207..a887d4b 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -21,7 +21,7 @@ On every session resolution the server compares the user's email against this li When the logged-in user is an admin, an **Admin** tab appears in **Settings → sidebar** with two sub-tabs: - **Shared providers** — server-side TTS provider instances visible to all users. -- **Site features** — runtime-editable replacements for what were previously `NEXT_PUBLIC_*` build-time flags. +- **Site features** — runtime-editable replacements for what were previously build-time public env flags. ## Shared TTS providers @@ -78,11 +78,11 @@ Runtime-editable settings, one row per key: | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | -Word-by-word highlighting and PDF layout parsing capability are controlled by `OPENREADER_COMPUTE_MODE` (server env), not an admin runtime flag. +Word-by-word highlighting and PDF layout parsing capability are controlled by `COMPUTE_MODE` (server env), not an admin runtime flag. Each row shows a source badge: -- **from env** — the value was migrated from the corresponding `NEXT_PUBLIC_*` env var on first boot. Editing it in the UI flips the source to **admin**. +- **from env** — the value was migrated from the corresponding `RUNTIME_SEED_*` env var on first boot. Editing it in the UI flips the source to **admin**. - **admin** — explicit admin override. Use **Reset** on the row to clear it back to the env-default state. - **default** — neither env nor admin set; uses the built-in default. @@ -92,11 +92,11 @@ Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through ## Migrating off env vars -The future-direction goal is to remove `NEXT_PUBLIC_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely: +The future-direction goal is to remove `RUNTIME_SEED_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely: 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - - Each `NEXT_PUBLIC_*` setting appears as **from env**. + - Each `RUNTIME_SEED_*` setting appears as **from env**. - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). 3. Remove the env vars from your `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 643823d..34e5053 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -31,7 +31,7 @@ ADMIN_EMAILS=alice@example.com,bob@example.com Admins see a new **Admin** tab in **Settings** with two sub-tabs: - **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users. -- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, audiobook export, etc.). +- **Site features** — runtime overrides for what were previously build-time public env flags (including account signup availability, default TTS provider, audiobook export, etc.). Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference. diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index 7a03e5d..1fa5961 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -11,7 +11,7 @@ OpenReader routes all TTS requests through the Next.js server to an OpenAI-compa **Environment variables**: `API_KEY` and `API_BASE` exist as a one-shot first-boot seed that auto-creates a `default-openai` admin shared provider. After the first boot they are no longer read by the running app. :::tip -If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. Legacy first-boot seed via `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows. +If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows. ::: ## Providers diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 1bc2183..7ab46c5 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -118,9 +118,9 @@ sudo apt install -y libreoffice No extra native Whisper CLI build step is required. -Set `OPENREADER_COMPUTE_MODE=local` to enable built-in ONNX word alignment in-process. +Set `COMPUTE_MODE=local` to enable built-in ONNX word alignment in-process. -If you need mirrors or pinned artifact locations, set `OPENREADER_WHISPER_MODEL_*_URL` overrides in `.env`. +If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env`. @@ -157,7 +157,7 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -OPENREADER_COMPUTE_MODE=local +COMPUTE_MODE=local # Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. # (Admin panel is unavailable without auth.) # API_BASE/API_KEY seed a shared default provider if you want shared mode. @@ -169,7 +169,7 @@ OPENREADER_COMPUTE_MODE=local ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -OPENREADER_COMPUTE_MODE=local +COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -184,7 +184,7 @@ AUTH_SECRET= # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -OPENREADER_COMPUTE_MODE=local +COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -197,7 +197,7 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -OPENREADER_COMPUTE_MODE=local +COMPUTE_MODE=local USE_EMBEDDED_WEED_MINI=false S3_BUCKET=your-bucket S3_REGION=us-east-1 @@ -212,21 +212,17 @@ S3_SECRET_ACCESS_KEY=your-secret-key :::note Env vars vs. admin panel -On first boot, `API_KEY` / `API_BASE` and any `NEXT_PUBLIC_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel). +On first boot, `API_KEY` / `API_BASE` and any `RUNTIME_SEED_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel). ::: :::note User BYOK restriction default -If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or via legacy first-boot seed `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows). +If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or via legacy first-boot seed `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows). ::: :::info For all environment variables, see [Environment Variables](../reference/environment-variables). ::: -:::tip Optional model prefetch -To pre-populate the PDF layout ONNX model cache before first PDF parse, run `pnpm fetch-models`. -::: - See [Auth](../configure/auth) for app/auth behavior. See [Admin Panel](../configure/admin-panel) for the shared-provider and feature-flag management UI. Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage). diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 10fe73a..e6053fd 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -38,7 +38,7 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in # Heavy compute (recommended on Vercel in v1) # local = requires native binaries/models in-process # none = disable ONNX whisper alignment + PDF layout parsing -OPENREADER_COMPUTE_MODE=none +COMPUTE_MODE=none # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) API_KEY=your_replicate_key @@ -66,7 +66,7 @@ After the first successful deploy and admin login, open **Settings → Admin** a ## 3. Legacy first-boot seed (optional) -If you must pre-seed site features via environment variables, the legacy `NEXT_PUBLIC_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management. +If you must pre-seed site features via environment variables, the legacy `RUNTIME_SEED_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management. See [Environment Variables](../reference/environment-variables#legacy-first-boot-runtime-seeds-optional) for the complete legacy seed list. @@ -129,4 +129,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If you run with local compute (`OPENREADER_COMPUTE_MODE=local`) outside Vercel, verify word highlighting timestamps on a TTS run. +4. If you run with local compute (`COMPUTE_MODE=local`) outside Vercel, verify word highlighting timestamps on a TTS run. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 6af01e8..933cc29 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -113,7 +113,7 @@ What this command enables: - Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**. - Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. The admin panel requires auth. - Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings. -- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` is still supported. +- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported. - Use a `/app/docstore` mount if you want data to survive container/image replacement. - Startup automatically runs DB/storage migrations via the shared entrypoint. ::: diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 9c58793..f04133b 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -6,7 +6,7 @@ toc_max_heading_level: 3 This is the single reference page for OpenReader environment variables. :::note Recommended configuration path -For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `NEXT_PUBLIC_*`) are optional first-boot seeds only. +For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `RUNTIME_SEED_*`) are optional first-boot seeds only. ::: ## Quick Reference Table @@ -16,9 +16,9 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) | | `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | | `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | -| `NEXT_PUBLIC_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | -| `NEXT_PUBLIC_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | -| `NEXT_PUBLIC_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | | `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL | | `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx | @@ -53,14 +53,11 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing | -| `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | -| `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | -| `OPENREADER_PDF_LAYOUT_MODEL_URL` | PDF layout model | PP-DocLayoutV3 ONNX URL | Override ONNX model URL for `ensureModel()` | -| `OPENREADER_PDF_LAYOUT_MODEL_DATA_URL` | PDF layout model | PP-DocLayoutV3 ONNX data URL | Override ONNX external data URL for `ensureModel()` | -| `OPENREADER_PDF_LAYOUT_CONFIG_URL` | PDF layout model | PP-DocLayoutV3 config URL | Override model config URL for `ensureModel()` | -| `OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL` | PDF layout model | PP-DocLayoutV3 preprocessor URL | Override model preprocessor URL for `ensureModel()` | -| `OPENREADER_WHISPER_MODEL_*_URL` | Whisper ONNX model | onnx-community defaults | Optional per-artifact URL overrides for ONNX whisper-base_timestamped int8 downloads | +| `COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing | +| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | +| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped int8 downloads | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -353,7 +350,7 @@ Multiple library roots for server library import. ## Audio Tooling and Alignment -### OPENREADER_COMPUTE_MODE +### COMPUTE_MODE Selects the backend for heavy compute features (ONNX word alignment + PDF layout parsing). @@ -363,69 +360,38 @@ Selects the backend for heavy compute features (ONNX word alignment + PDF layout - `none`: disable these features cleanly - `worker` is reserved for a future external worker backend and currently fails fast at startup if selected -### OPENREADER_COMPUTE_WORKER_URL +### COMPUTE_WORKER_URL Reserved for future external compute worker mode. -- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Used only when `COMPUTE_MODE=worker` (not implemented in v1) - Leave unset in v1 -### OPENREADER_COMPUTE_WORKER_TOKEN +### COMPUTE_WORKER_TOKEN Reserved bearer token for future external compute worker mode. -- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Used only when `COMPUTE_MODE=worker` (not implemented in v1) - Leave unset in v1 -### OPENREADER_PDF_LAYOUT_MODEL_URL +### PDF_LAYOUT_MODEL_BASE_URL -Override URL for the PP-DocLayoutV3 ONNX model downloaded by `ensureModel()`. +Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`. -- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx` -- Optional for custom mirrors/air-gapped workflows +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main` +- Required files at that base: + - `PP-DocLayoutV3.onnx` + - `PP-DocLayoutV3.onnx.data` + - `config.json` + - `preprocessor_config.json` -### OPENREADER_PDF_LAYOUT_MODEL_DATA_URL +### WHISPER_MODEL_BASE_URL -Override URL for the PP-DocLayoutV3 ONNX external data file downloaded by `ensureModel()`. +Optional base URL override for the built-in ONNX Whisper alignment model downloader. -- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data` - -### OPENREADER_PDF_LAYOUT_CONFIG_URL - -Override URL for the PP-DocLayoutV3 `config.json` downloaded by `ensureModel()`. - -- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json` - -### OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL - -Override URL for the PP-DocLayoutV3 `preprocessor_config.json` downloaded by `ensureModel()`. - -- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json` -- You can pre-populate the model cache via `pnpm fetch-models` - -### OPENREADER_WHISPER_MODEL_*_URL - -Optional per-artifact override URLs for the built-in ONNX Whisper alignment model downloader. - -- Default base: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` +- Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` - Default model variant: int8 (`encoder_model_int8.onnx`, `decoder_model_merged_int8.onnx`, `decoder_with_past_model_int8.onnx`) -- Use these when you need mirrors, pinned snapshots, or air-gapped fetch routing. - -Supported override vars: - -- `OPENREADER_WHISPER_MODEL_CONFIG_URL` -- `OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL` -- `OPENREADER_WHISPER_MODEL_TOKENIZER_URL` -- `OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL` -- `OPENREADER_WHISPER_MODEL_MERGES_URL` -- `OPENREADER_WHISPER_MODEL_VOCAB_URL` -- `OPENREADER_WHISPER_MODEL_NORMALIZER_URL` -- `OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL` -- `OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL` -- `OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL` -- `OPENREADER_WHISPER_MODEL_ENCODER_URL` -- `OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL` -- `OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL` +- The base URL must host all expected manifest files under the same relative paths. ### FFMPEG_BIN @@ -438,23 +404,23 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior. -The values are SSR-injected via `window.__OPENREADER_RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). +The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old build-time public env pattern). -### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION +### RUNTIME_SEED_ENABLE_DOCX_CONVERSION Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled. - Default: `true` (enabled) - Runtime key: `enableDocxConversion` -### NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS +### RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings. - Default: `true` (enabled) - Runtime key: `enableDestructiveDeleteActions` -### NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB +### RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB Controls whether the **TTS Provider** section appears in the user-facing Settings modal. @@ -462,7 +428,7 @@ Controls whether the **TTS Provider** section appears in the user-facing Setting - Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected). - Runtime key: `enableTtsProvidersTab` -### NEXT_PUBLIC_ENABLE_USER_SIGNUPS +### RUNTIME_SEED_ENABLE_USER_SIGNUPS Controls whether new user accounts can be created. @@ -471,7 +437,7 @@ Controls whether new user accounts can be created. - Existing users can still sign in. - Runtime key: `enableUserSignups` -### NEXT_PUBLIC_RESTRICT_USER_API_KEYS +### RUNTIME_SEED_RESTRICT_USER_API_KEYS Controls whether users can supply personal API keys/base URLs for built-in providers. @@ -480,7 +446,7 @@ Controls whether users can supply personal API keys/base URLs for built-in provi - When `false`, users can use per-user BYOK credentials for built-in providers. - Runtime key: `restrictUserApiKeys` -### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER +### RUNTIME_SEED_DEFAULT_TTS_PROVIDER Sets the default TTS provider for new users. @@ -490,7 +456,7 @@ Sets the default TTS provider for new users. `showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**. -### NEXT_PUBLIC_CHANGELOG_FEED_URL +### RUNTIME_SEED_CHANGELOG_FEED_URL Sets the changelog manifest URL used by the Settings modal changelog viewer. @@ -499,7 +465,7 @@ Sets the changelog manifest URL used by the Settings modal changelog viewer. - Runtime key: `changelogFeedUrl` -### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT +### RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT Controls whether audiobook export UI/actions are shown in the client. diff --git a/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md b/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md index ef79788..781afbb 100644 --- a/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md +++ b/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md @@ -364,7 +364,7 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior. -The values are SSR-injected via `window.__OPENREADER_RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). +The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). ### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION diff --git a/next.config.ts b/next.config.ts index b35c2d0..0773b73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,6 +14,16 @@ const securityHeaders = [ value: 'max-age=63072000; includeSubDomains; preload', }, ]; + +const computeMode = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); +const computeDisabled = computeMode === 'none'; +const serverExternalPackages = [ + '@napi-rs/canvas', + 'ffmpeg-static', + 'better-sqlite3', + ...(computeDisabled ? [] : ['onnxruntime-node', '@huggingface/tokenizers']), +]; + const nextConfig: NextConfig = { async headers() { return [ @@ -29,13 +39,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - serverExternalPackages: [ - "@napi-rs/canvas", - "ffmpeg-static", - "better-sqlite3", - "onnxruntime-node", - "@huggingface/tokenizers", - ], + serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ './node_modules/ffmpeg-static/ffmpeg', @@ -56,6 +60,16 @@ const nextConfig: NextConfig = { './node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs', ], }, + ...(computeDisabled + ? { + outputFileTracingExcludes: { + '/*': [ + './node_modules/onnxruntime-node/**/*', + './node_modules/@huggingface/tokenizers/**/*', + ], + }, + } + : {}), }; export default nextConfig; diff --git a/package.json b/package.json index 63197cf..115a93e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "generate": "node drizzle/scripts/generate.mjs", - "fetch-models": "node scripts/fetch-models.mjs", "docs:init": "pnpm --dir docs-site install", "docs:dev": "pnpm --dir docs-site start", "docs:build": "pnpm --dir docs-site build", diff --git a/scripts/fetch-models.mjs b/scripts/fetch-models.mjs deleted file mode 100644 index edf56c2..0000000 --- a/scripts/fetch-models.mjs +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -const modelDir = path.join(process.cwd(), 'docstore', 'model'); -const modelPath = path.join(modelDir, 'PP-DocLayoutV3.onnx'); -const modelDataPath = path.join(modelDir, 'PP-DocLayoutV3.onnx.data'); -const configPath = path.join(modelDir, 'pp-doclayoutv3.config.json'); -const preprocessorPath = path.join(modelDir, 'pp-doclayoutv3.preprocessor_config.json'); -const licensePath = path.join(modelDir, 'pp-doclayoutv3.LICENSE.txt'); -const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt'); - -const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL - || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx'; -const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL - || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data'; -const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL - || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json'; -const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL - || 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json'; - -await mkdir(modelDir, { recursive: true }); - -const modelRes = await fetch(modelUrl); -if (!modelRes.ok) { - throw new Error(`Failed to fetch model: ${modelRes.status} ${modelRes.statusText}`); -} -await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer())); - -const modelDataRes = await fetch(modelDataUrl); -if (!modelDataRes.ok) { - throw new Error(`Failed to fetch model data: ${modelDataRes.status} ${modelDataRes.statusText}`); -} -await writeFile(modelDataPath, new Uint8Array(await modelDataRes.arrayBuffer())); - -const configRes = await fetch(configUrl); -if (!configRes.ok) { - throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`); -} -await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer())); - -const preprocessorRes = await fetch(preprocessorUrl); -if (!preprocessorRes.ok) { - throw new Error(`Failed to fetch preprocessor config: ${preprocessorRes.status} ${preprocessorRes.statusText}`); -} -await writeFile(preprocessorPath, new Uint8Array(await preprocessorRes.arrayBuffer())); - -const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath)); -await writeFile(licensePath, staticLicense); - -console.log(`Saved model to ${modelPath}`); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1ebd8c7..1315dbe 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,7 +56,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) ...runtimeConfig, appVersion: pkg.version, }; - const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`; + const runtimeConfigInit = `window.__RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`; return ( diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index e1e3991..1870b8e 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -4,8 +4,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; /** * Site-wide runtime config resolved at SSR time and injected via - * `window.__OPENREADER_RUNTIME_CONFIG__`. Replaces module-scope reads of - * `process.env.NEXT_PUBLIC_*` so admin edits take effect on the next page + * `window.__RUNTIME_CONFIG__`. Replaces module-scope reads of + * build-time public env flags so admin edits take effect on the next page * load without a redeploy. Read-only from the client; admin writes go * through `/api/admin/settings` and trigger a reload. * @@ -42,13 +42,13 @@ const RUNTIME_DEFAULTS: RuntimeConfig = { declare global { // Injected via SSR in `src/app/layout.tsx`. Always defined in the browser. interface Window { - __OPENREADER_RUNTIME_CONFIG__?: Partial; + __RUNTIME_CONFIG__?: Partial; } } function readInjectedConfig(): RuntimeConfig { if (typeof window === 'undefined') return { ...RUNTIME_DEFAULTS }; - const injected = window.__OPENREADER_RUNTIME_CONFIG__; + const injected = window.__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return { ...RUNTIME_DEFAULTS }; return { ...RUNTIME_DEFAULTS, ...injected }; } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 8bbc362..d96d23e 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -217,7 +217,7 @@ const WARM_AUDIO_CACHE_MAX_ITEMS = 6; const wordHighlightFeatureEnabled = (() => { if (typeof window === 'undefined') return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; + const injected = (window as any).__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return true; return typeof injected.computeAvailable === 'boolean' ? injected.computeAvailable diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 18e3896..7cbc2cc 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -12,7 +12,7 @@ import { * Idempotent boot-time seeding for the admin layer. Safe to call multiple * times. Runs: * - * 1. `seedRuntimeConfigFromEnv()` — for each `NEXT_PUBLIC_*` env var that + * 1. `seedRuntimeConfigFromEnv()` — for each `RUNTIME_SEED_*` env var that * maps to a runtime config key, write the value as `source='env-seed'`. * * 2. Default admin provider seed — if `admin_providers` is empty AND diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 598d46f..eb2a77f 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -4,7 +4,7 @@ import { adminProviders, adminSettings } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth/config'; /** - * Runtime config: site-wide settings that used to live in `NEXT_PUBLIC_*` + * Runtime config: site-wide settings that used to live in build-time env vars. * env vars. Each key has: * - a TypeScript value type * - an env var name used for the first-run seed @@ -75,16 +75,16 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef< } export const RUNTIME_CONFIG_SCHEMA = { - defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'), - changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'NEXT_PUBLIC_CHANGELOG_FEED_URL'), - enableUserSignups: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_USER_SIGNUPS'), - restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'), + defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'), + changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'), + enableUserSignups: booleanFlag(true, 'RUNTIME_SEED_ENABLE_USER_SIGNUPS'), + restrictUserApiKeys: booleanFlag(true, 'RUNTIME_SEED_RESTRICT_USER_API_KEYS'), // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. - enableTtsProvidersTab: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB'), - enableAudiobookExport: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT'), - enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'), - enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), + enableTtsProvidersTab: booleanFlag(true, 'RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB'), + enableAudiobookExport: booleanFlag(true, 'RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT'), + enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'), + enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), showAllProviderModels: runtimeBoolean(true), } as const satisfies Record>; diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index b469a55..8b211b2 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -1,5 +1,4 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; -import { LocalComputeBackend } from '@/lib/server/compute/local'; import { NoneComputeBackend } from '@/lib/server/compute/none'; import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; @@ -10,9 +9,13 @@ function createBackend(): ComputeBackend { if (mode === 'none') return new NoneComputeBackend(); if (mode === 'worker') { throw new Error( - 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + 'COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', ); } + // Intentionally lazy-load local compute so COMPUTE_MODE=none builds + // can avoid tracing heavy ONNX dependencies. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { LocalComputeBackend } = require('@/lib/server/compute/local') as typeof import('@/lib/server/compute/local'); return new LocalComputeBackend(); } diff --git a/src/lib/server/compute/mode.ts b/src/lib/server/compute/mode.ts index 170d6c9..5ebd349 100644 --- a/src/lib/server/compute/mode.ts +++ b/src/lib/server/compute/mode.ts @@ -1,7 +1,7 @@ import type { ComputeMode } from '@/lib/server/compute/types'; export function readComputeMode(): ComputeMode { - const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase(); + const raw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; return 'local'; } @@ -9,7 +9,7 @@ export function readComputeMode(): ComputeMode { export function isComputeModeAvailable(mode: ComputeMode): boolean { if (mode === 'worker') { throw new Error( - 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + 'COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', ); } return mode !== 'none'; diff --git a/src/lib/server/compute/none.ts b/src/lib/server/compute/none.ts index a2f6695..64202e8 100644 --- a/src/lib/server/compute/none.ts +++ b/src/lib/server/compute/none.ts @@ -7,11 +7,11 @@ export class NoneComputeBackend implements ComputeBackend { async alignWords(input: WhisperAlignInput): Promise { void input; - throw new UnsupportedComputeError('Word alignment is unavailable: OPENREADER_COMPUTE_MODE=none'); + throw new UnsupportedComputeError('Word alignment is unavailable: COMPUTE_MODE=none'); } async parsePdfLayout(input: PdfLayoutInput): Promise { void input; - throw new UnsupportedComputeError('PDF layout parsing is unavailable: OPENREADER_COMPUTE_MODE=none'); + throw new UnsupportedComputeError('PDF layout parsing is unavailable: COMPUTE_MODE=none'); } } diff --git a/src/lib/server/pdf-layout/ensureModel.ts b/src/lib/server/pdf-layout/ensureModel.ts index e39dd62..affe4b9 100644 --- a/src/lib/server/pdf-layout/ensureModel.ts +++ b/src/lib/server/pdf-layout/ensureModel.ts @@ -4,10 +4,8 @@ import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; import manifest from '@/lib/server/pdf-layout/model/manifest.json'; -const DEFAULT_MODEL_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx'; -const DEFAULT_MODEL_DATA_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/PP-DocLayoutV3.onnx.data'; -const DEFAULT_CONFIG_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/config.json'; -const DEFAULT_PREPROCESSOR_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main/preprocessor_config.json'; +const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main'; +const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL'; const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx'); export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data'); @@ -32,6 +30,10 @@ async function downloadToFile(url: string, outPath: string): Promise { await writeFile(outPath, bytes); } +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + function manifestEntry(filePath: string): { sha256: string; size: number } | null { const found = manifest.files.find((entry) => entry.path === filePath); if (!found || !found.sha256) return null; @@ -83,14 +85,12 @@ async function ensureModelInternal(): Promise { const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`; const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`; const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`; - const modelUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_URL?.trim() - || DEFAULT_MODEL_URL; - const modelDataUrl = process.env.OPENREADER_PDF_LAYOUT_MODEL_DATA_URL?.trim() - || DEFAULT_MODEL_DATA_URL; - const configUrl = process.env.OPENREADER_PDF_LAYOUT_CONFIG_URL?.trim() - || DEFAULT_CONFIG_URL; - const preprocessorUrl = process.env.OPENREADER_PDF_LAYOUT_PREPROCESSOR_URL?.trim() - || DEFAULT_PREPROCESSOR_URL; + const modelBaseUrl = process.env[PDF_LAYOUT_MODEL_BASE_URL_ENV]?.trim() + || DEFAULT_MODEL_BASE_URL; + const modelUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx'); + const modelDataUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx.data'); + const configUrl = joinModelUrl(modelBaseUrl, 'config.json'); + const preprocessorUrl = joinModelUrl(modelBaseUrl, 'preprocessor_config.json'); await downloadToFile(modelUrl, modelTmpPath); if (!(await verifyFile(modelTmpPath, 'model.onnx'))) { diff --git a/src/lib/server/whisper/ensureModel.ts b/src/lib/server/whisper/ensureModel.ts index 6d1ae77..53b1d1b 100644 --- a/src/lib/server/whisper/ensureModel.ts +++ b/src/lib/server/whisper/ensureModel.ts @@ -16,6 +16,23 @@ export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'd export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_int8.onnx'); const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main'; +const WHISPER_MODEL_BASE_URL_ENV = 'WHISPER_MODEL_BASE_URL'; + +const MODEL_RELATIVE_PATHS: string[] = [ + 'config.json', + 'generation_config.json', + 'tokenizer.json', + 'tokenizer_config.json', + 'merges.txt', + 'vocab.json', + 'normalizer.json', + 'added_tokens.json', + 'preprocessor_config.json', + 'special_tokens_map.json', + 'onnx/encoder_model_int8.onnx', + 'onnx/decoder_model_merged_int8.onnx', + 'onnx/decoder_with_past_model_int8.onnx', +]; const DEFAULT_URLS: Record = { 'config.json': `${BASE_MODEL_URL}/config.json`, @@ -33,22 +50,6 @@ const DEFAULT_URLS: Record = { 'onnx/decoder_with_past_model_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_int8.onnx`, }; -const ENV_URL_OVERRIDES: Record = { - 'config.json': 'OPENREADER_WHISPER_MODEL_CONFIG_URL', - 'generation_config.json': 'OPENREADER_WHISPER_MODEL_GENERATION_CONFIG_URL', - 'tokenizer.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_URL', - 'tokenizer_config.json': 'OPENREADER_WHISPER_MODEL_TOKENIZER_CONFIG_URL', - 'merges.txt': 'OPENREADER_WHISPER_MODEL_MERGES_URL', - 'vocab.json': 'OPENREADER_WHISPER_MODEL_VOCAB_URL', - 'normalizer.json': 'OPENREADER_WHISPER_MODEL_NORMALIZER_URL', - 'added_tokens.json': 'OPENREADER_WHISPER_MODEL_ADDED_TOKENS_URL', - 'preprocessor_config.json': 'OPENREADER_WHISPER_MODEL_PREPROCESSOR_URL', - 'special_tokens_map.json': 'OPENREADER_WHISPER_MODEL_SPECIAL_TOKENS_MAP_URL', - 'onnx/encoder_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_ENCODER_URL', - 'onnx/decoder_model_merged_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_MERGED_URL', - 'onnx/decoder_with_past_model_int8.onnx': 'OPENREADER_WHISPER_MODEL_DECODER_WITH_PAST_URL', -}; - type ManifestEntry = { path: string; sha256?: string; size?: number }; export interface WhisperArtifactSpec { @@ -82,10 +83,15 @@ function resolvePath(relativePath: string, modelDir: string): string { return path.join(modelDir, relativePath); } +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + function resolveUrl(relativePath: string): string { - const envKey = ENV_URL_OVERRIDES[relativePath]; - const override = envKey ? process.env[envKey]?.trim() : ''; - if (override) return override; + const overrideBase = process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim(); + if (overrideBase) { + return joinModelUrl(overrideBase, relativePath); + } const fallback = DEFAULT_URLS[relativePath]; if (!fallback) { throw new Error(`No default URL configured for Whisper model artifact: ${relativePath}`); @@ -194,6 +200,14 @@ export function createSingleflightRunner(work: () => Promise): () => Promi } async function ensureModelInternal(): Promise { + if (process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim()) { + for (const relativePath of MODEL_RELATIVE_PATHS) { + if (!(relativePath in DEFAULT_URLS)) { + throw new Error(`Missing default URL path mapping for Whisper artifact: ${relativePath}`); + } + } + } + const artifacts: WhisperArtifactSpec[] = MODEL_FILES.map((entry) => ({ path: entry.path, sha256: entry.sha256, diff --git a/src/types/config.ts b/src/types/config.ts index 03881c3..58bb388 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -5,13 +5,13 @@ import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; // Runtime config (admin-controlled) is layered on top of the static defaults // below. We resolve it lazily so this module stays importable from non-React // contexts (Dexie, server routes). The actual values come from -// `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and +// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and // from the built-in defaults during SSR. function readRuntimeString(key: string, defaultValue: string): string { if (typeof window === 'undefined') return defaultValue; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; + const injected = (window as any).__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return defaultValue; const value = injected[key]; return typeof value === 'string' && value ? value : defaultValue; @@ -80,7 +80,7 @@ export interface AppConfigValues { /** * Build defaults lazily so we can read SSR-injected admin overrides - * (`window.__OPENREADER_RUNTIME_CONFIG__`). Modules that need the defaults + * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults * statically should call `getAppConfigDefaults()` at use time. The exported * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so * mutations to the runtime config (admin edits) are picked up by anything From f1aa1c3e3ba43bb0706a79084b5737dabcf8eca8 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 15:21:25 -0600 Subject: [PATCH 009/137] feat(compute): add external compute worker backend and integration Introduce support for external compute worker mode (`COMPUTE_MODE=worker`) using a new `WorkerComputeBackend`. This enables offloading heavy ONNX Whisper alignment and PDF layout parsing to a standalone worker service (Redis + BullMQ), improving scalability and compatibility with serverless/limited environments. - Add `@openreader/compute-core` as a shared package for ONNX inference and PDF parsing logic. - Implement `WorkerComputeBackend` and worker contract/types for remote job execution. - Update compute backend selection logic and remove previous worker mode guards. - Extend `WhisperAlignInput` and `PdfLayoutInput` types to support object keys for remote data access. - Refactor local compute backend to use `@openreader/compute-core` and support both buffer and object key inputs. - Update job runner, TTS segment alignment, and PDF layout parsing flows to use new compute backend APIs. - Add scripts, Docker workflow, and documentation for deploying and running the compute worker. - Update environment variable docs and examples for worker mode, including storage requirements and configuration. - Document published images and stack changes to reflect the new compute worker architecture. BREAKING CHANGE: `COMPUTE_MODE=worker` now requires an external compute worker service and S3-compatible object storage. Embedded SeaweedFS (`weed mini`) is not supported in worker mode. See the new documentation for deployment and configuration details. --- .env.example | 9 +- .github/workflows/docker-publish.yml | 126 +- README.md | 3 +- compute/core/package.json | 17 + compute/core/src/index.ts | 87 ++ compute/core/src/pdf-layout/ensureModel.ts | 132 +++ .../src/pdf-layout/mergeTextWithRegions.ts | 153 +++ compute/core/src/pdf-layout/model/LICENSE.txt | 3 + .../core/src/pdf-layout/model/manifest.json | 31 + compute/core/src/pdf-layout/parsePdf.ts | 165 +++ compute/core/src/pdf-layout/renderPage.ts | 162 +++ compute/core/src/pdf-layout/runLayoutModel.ts | 331 ++++++ .../src/pdf-layout/stitchCrossPageBlocks.ts | 144 +++ compute/core/src/pdf-layout/types.ts | 15 + compute/core/src/runtime/docstore.ts | 7 + compute/core/src/runtime/ffmpeg.ts | 36 + compute/core/src/types/parsed-pdf.ts | 53 + compute/core/src/types/tts.ts | 16 + compute/core/src/whisper/alignment-mapping.ts | 54 + compute/core/src/whisper/alignment.ts | 1012 +++++++++++++++++ compute/core/src/whisper/ensureModel.ts | 242 ++++ compute/core/src/whisper/model/LICENSE.txt | 21 + compute/core/src/whisper/model/manifest.json | 76 ++ .../core/src/whisper/model/mel_filters.npz | Bin 0 -> 4271 bytes compute/core/src/whisper/spectral.ts | 21 + compute/core/src/whisper/token-timestamps.ts | 449 ++++++++ compute/core/tsconfig.json | 7 + compute/worker/.env.example | 25 + compute/worker/Dockerfile | 20 + compute/worker/docker-compose.yml | 41 + compute/worker/package.json | 23 + compute/worker/src/server.ts | 432 +++++++ compute/worker/tsconfig.json | 7 + docs-site/docs/deploy/compute-worker.md | 64 ++ docs-site/docs/deploy/local-development.md | 53 + docs-site/docs/deploy/vercel-deployment.md | 13 +- docs-site/docs/docker-quick-start.md | 7 + .../docs/reference/environment-variables.md | 22 +- docs-site/docs/reference/stack.md | 32 +- docs-site/sidebars.ts | 2 +- next.config.ts | 12 +- package.json | 6 +- pnpm-lock.yaml | 593 ++++++++++ pnpm-workspace.yaml | 1 + src/app/api/tts/segments/ensure/route.ts | 5 +- src/lib/server/compute/index.ts | 7 +- src/lib/server/compute/local.ts | 39 +- src/lib/server/compute/mode.ts | 5 - src/lib/server/compute/types.ts | 10 +- src/lib/server/compute/worker-contract.ts | 11 + src/lib/server/compute/worker.ts | 165 +++ src/lib/server/jobs/parsePdfJob.ts | 6 +- tsconfig.json | 3 +- 53 files changed, 4880 insertions(+), 96 deletions(-) create mode 100644 compute/core/package.json create mode 100644 compute/core/src/index.ts create mode 100644 compute/core/src/pdf-layout/ensureModel.ts create mode 100644 compute/core/src/pdf-layout/mergeTextWithRegions.ts create mode 100644 compute/core/src/pdf-layout/model/LICENSE.txt create mode 100644 compute/core/src/pdf-layout/model/manifest.json create mode 100644 compute/core/src/pdf-layout/parsePdf.ts create mode 100644 compute/core/src/pdf-layout/renderPage.ts create mode 100644 compute/core/src/pdf-layout/runLayoutModel.ts create mode 100644 compute/core/src/pdf-layout/stitchCrossPageBlocks.ts create mode 100644 compute/core/src/pdf-layout/types.ts create mode 100644 compute/core/src/runtime/docstore.ts create mode 100644 compute/core/src/runtime/ffmpeg.ts create mode 100644 compute/core/src/types/parsed-pdf.ts create mode 100644 compute/core/src/types/tts.ts create mode 100644 compute/core/src/whisper/alignment-mapping.ts create mode 100644 compute/core/src/whisper/alignment.ts create mode 100644 compute/core/src/whisper/ensureModel.ts create mode 100644 compute/core/src/whisper/model/LICENSE.txt create mode 100644 compute/core/src/whisper/model/manifest.json create mode 100644 compute/core/src/whisper/model/mel_filters.npz create mode 100644 compute/core/src/whisper/spectral.ts create mode 100644 compute/core/src/whisper/token-timestamps.ts create mode 100644 compute/core/tsconfig.json create mode 100644 compute/worker/.env.example create mode 100644 compute/worker/Dockerfile create mode 100644 compute/worker/docker-compose.yml create mode 100644 compute/worker/package.json create mode 100644 compute/worker/src/server.ts create mode 100644 compute/worker/tsconfig.json create mode 100644 docs-site/docs/deploy/compute-worker.md create mode 100644 src/lib/server/compute/worker-contract.ts create mode 100644 src/lib/server/compute/worker.ts diff --git a/.env.example b/.env.example index 3b38e23..74afbf0 100644 --- a/.env.example +++ b/.env.example @@ -80,10 +80,13 @@ IMPORT_LIBRARY_DIRS= # Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. # local = run compute in-process (default) # none = disable both capabilities (good for preview/serverless) -# worker = reserved for future external worker mode (not implemented in v1) +# worker = external durable worker mode (requires Redis-backed compute-worker service) COMPUTE_MODE=local -# COMPUTE_WORKER_URL= -# COMPUTE_WORKER_TOKEN= +# Required when COMPUTE_MODE=worker +# COMPUTE_WORKER_URL=http://localhost:8081 +# COMPUTE_WORKER_TOKEN=local-compute-token +# Worker mode requires worker-reachable shared object storage. +# Non-exposed embedded weed mini is not supported in worker mode. # Optional Whisper ONNX base URL override (must contain all expected files) # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cae0b8f..2c50173 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,30 +22,17 @@ jobs: prepare: runs-on: ubuntu-24.04 outputs: - image_name: ${{ steps.image-name.outputs.image_name }} - legacy_image_name: ${{ steps.image-name.outputs.legacy_image_name }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + web_image_name: ${{ steps.image-name.outputs.web_image_name }} + web_legacy_image_name: ${{ steps.image-name.outputs.web_legacy_image_name }} + compute_worker_image_name: ${{ steps.image-name.outputs.compute_worker_image_name }} steps: - name: Compute Docker image names id: image-name run: | owner="${GITHUB_REPOSITORY_OWNER,,}" - echo "image_name=${owner}/openreader" >> "$GITHUB_OUTPUT" - echo "legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT" - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v6 - with: - images: | - ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }} - ${{ env.REGISTRY }}/${{ steps.image-name.outputs.legacy_image_name }} - tags: | - type=raw,value=latest,enable=${{ (github.event_name == 'push' && !contains(github.ref, '-pre')) || (github.event_name == 'workflow_dispatch' && inputs.use_latest_tag == true) }},priority=1000 - type=semver,pattern={{version}} - type=ref,event=tag - type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && inputs.use_latest_tag != true }} + echo "web_image_name=${owner}/openreader" >> "$GITHUB_OUTPUT" + echo "web_legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT" + echo "compute_worker_image_name=${owner}/openreader-compute-worker" >> "$GITHUB_OUTPUT" build: needs: prepare @@ -58,12 +45,30 @@ jobs: fail-fast: false matrix: include: - - arch: amd64 + - image_target: web + arch: amd64 platform: linux/amd64 runner: ubuntu-24.04 - - arch: arm64 + context: . + dockerfile: ./Dockerfile + - image_target: web + arch: arm64 platform: linux/arm64 runner: ubuntu-24.04-arm + context: . + dockerfile: ./Dockerfile + - image_target: compute-worker + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + context: . + dockerfile: ./compute/worker/Dockerfile + - image_target: compute-worker + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + context: . + dockerfile: ./compute/worker/Dockerfile steps: - name: Checkout repository @@ -82,46 +87,63 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image (${{ matrix.arch }}) + - name: Select image name + id: image + run: | + if [ "${{ matrix.image_target }}" = "web" ]; then + echo "image_name=${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + else + echo "image_name=${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image (${{ matrix.image_target }} / ${{ matrix.arch }}) id: build uses: docker/build-push-action@v7 with: - context: . + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} - labels: ${{ needs.prepare.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.image_target }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.image_target }}-${{ matrix.arch }} provenance: false - outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.image.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true - name: Export digest run: | - mkdir -p /tmp/digests + mkdir -p /tmp/digests/${{ matrix.image_target }} digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" + touch "/tmp/digests/${{ matrix.image_target }}/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v7 with: - name: digests-${{ matrix.arch }} - path: /tmp/digests + name: digests-${{ matrix.image_target }}-${{ matrix.arch }} + path: /tmp/digests/${{ matrix.image_target }} if-no-files-found: error retention-days: 1 merge: needs: [prepare, build] - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} permissions: actions: read contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - image_target: web + runner: ubuntu-24.04 + - image_target: compute-worker + runner: ubuntu-24.04 steps: - name: Download digests uses: actions/download-artifact@v8 with: - path: /tmp/digests - pattern: digests-* + path: /tmp/digests/${{ matrix.image_target }} + pattern: digests-${{ matrix.image_target }}-* merge-multiple: true - name: Set up Docker Buildx @@ -134,20 +156,46 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Select image names + id: image + run: | + if [ "${{ matrix.image_target }}" = "web" ]; then + echo "image_name=${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + echo "metadata_images<> "$GITHUB_OUTPUT" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.web_legacy_image_name }}" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "image_name=${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + echo "metadata_images=${{ env.REGISTRY }}/${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.image.outputs.metadata_images }} + tags: | + type=raw,value=latest,enable=${{ (github.event_name == 'push' && !contains(github.ref, '-pre')) || (github.event_name == 'workflow_dispatch' && inputs.use_latest_tag == true) }},priority=1000 + type=semver,pattern={{version}} + type=ref,event=tag + type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && inputs.use_latest_tag != true }} + - name: Create manifest list and push - working-directory: /tmp/digests + working-directory: /tmp/digests/${{ matrix.image_target }} run: | docker buildx imagetools create \ $(echo "$TAGS" | xargs -I {} echo -t {}) \ - $(printf '${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}@sha256:%s ' *) + $(printf '${{ env.REGISTRY }}/${{ steps.image.outputs.image_name }}@sha256:%s ' *) env: - TAGS: ${{ needs.prepare.outputs.tags }} + TAGS: ${{ steps.meta.outputs.tags }} - name: Output build information run: | - echo "✅ Docker images built and pushed successfully!" + echo "✅ Docker image built and pushed successfully!" + echo "🐋 Target: ${{ matrix.image_target }}" echo "🐋 Images:" - echo '${{ needs.prepare.outputs.tags }}' | sed 's/^/ - /' + echo '${{ steps.meta.outputs.tags }}' | sed 's/^/ - /' echo "📝 Event: ${{ github.event_name }}" if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "📝 Triggered by manual workflow dispatch on branch: ${{ github.ref_name }}" diff --git a/README.md b/README.md index 73dfdc9..5fc22dd 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment in local compute mode (`COMPUTE_MODE=local`). +- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment in local mode (`COMPUTE_MODE=local`) or external worker mode (`COMPUTE_MODE=worker`). - 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. @@ -33,6 +33,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader | --- | --- | | Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/docker-quick-start) | | Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/deploy/vercel-deployment) | +| Deploy external compute worker | [Compute Worker (Redis + BullMQ)](https://docs.openreader.richardr.dev/deploy/compute-worker) | | Develop locally | [Local Development](https://docs.openreader.richardr.dev/deploy/local-development) | | Configure auth | [Auth](https://docs.openreader.richardr.dev/configure/auth) | | Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/configure/database) | diff --git a/compute/core/package.json b/compute/core/package.json new file mode 100644 index 0000000..fe9bb45 --- /dev/null +++ b/compute/core/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openreader/compute-core", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@huggingface/tokenizers": "^0.1.3", + "@napi-rs/canvas": "^0.1.100", + "ffmpeg-static": "^5.3.0", + "jszip": "^3.10.1", + "onnxruntime-node": "^1.26.0", + "pdfjs-dist": "4.8.69" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts new file mode 100644 index 0000000..6f9ca15 --- /dev/null +++ b/compute/core/src/index.ts @@ -0,0 +1,87 @@ +import type { TTSSentenceAlignment } from './types/tts'; +import type { ParsedPdfDocument } from './types/parsed-pdf'; +import { ensureWhisperModel } from './whisper/ensureModel'; +import { alignAudioWithText } from './whisper/alignment'; +import { ensureModel as ensurePdfLayoutModel } from './pdf-layout/ensureModel'; +import { parsePdf } from './pdf-layout/parsePdf'; + +export type { + TTSAudioBuffer, + TTSAudioBytes, + TTSSentenceAlignment, + TTSSentenceWord, +} from './types/tts'; +export type { + ParsedPdfBlockKind, + ParsedPdfBlockFragment, + ParsedPdfBlock, + ParsedPdfPage, + ParsedPdfDocument, +} from './types/parsed-pdf'; + +export const ALIGN_QUEUE_NAME = 'whisper-align'; +export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout'; + +export interface WhisperAlignJobRequest { + text: string; + lang?: string; + cacheKey?: string; + audioObjectKey: string; +} + +export interface WhisperAlignJobResult { + alignments: TTSSentenceAlignment[]; +} + +export interface PdfLayoutJobRequest { + documentId: string; + namespace: string | null; + documentObjectKey: string; +} + +export interface PdfLayoutJobResult { + parsed: ParsedPdfDocument; +} + +export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; + +export interface WorkerJobErrorShape { + message: string; + code?: string; +} + +export interface WorkerJobStatusResponse { + status: WorkerJobState; + result?: Result; + error?: WorkerJobErrorShape; +} + +export async function ensureComputeModels(): Promise { + await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]); +} + +export async function runWhisperAlignmentFromAudioBuffer(input: { + audioBuffer: ArrayBuffer; + text: string; + cacheKey?: string; + lang?: string; +}): Promise { + const alignments = await alignAudioWithText( + input.audioBuffer, + input.text, + input.cacheKey, + { lang: input.lang }, + ); + return { alignments }; +} + +export async function runPdfLayoutFromPdfBuffer(input: { + documentId: string; + pdfBytes: ArrayBuffer; +}): Promise { + const parsed = await parsePdf({ + documentId: input.documentId, + pdfBytes: input.pdfBytes, + }); + return { parsed }; +} diff --git a/compute/core/src/pdf-layout/ensureModel.ts b/compute/core/src/pdf-layout/ensureModel.ts new file mode 100644 index 0000000..a7a95da --- /dev/null +++ b/compute/core/src/pdf-layout/ensureModel.ts @@ -0,0 +1,132 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; +import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '../runtime/docstore'; +import manifest from './model/manifest.json'; + +const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main'; +const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL'; +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); +export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx'); +export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data'); +export const MODEL_CONFIG_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.config.json'); +export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.preprocessor_config.json'); +const LICENSE_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.LICENSE.txt'); +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const STATIC_LICENSE_PATH = path.join(MODULE_DIR, 'model', 'LICENSE.txt'); + +let inflight: Promise | null = null; + +async function sha256Hex(filePath: string): Promise { + const bytes = await readFile(filePath); + return createHash('sha256').update(bytes).digest('hex'); +} + +async function downloadToFile(url: string, outPath: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + +function manifestEntry(filePath: string): { sha256: string; size: number } | null { + const found = manifest.files.find((entry) => entry.path === filePath); + if (!found || !found.sha256) return null; + return { + sha256: found.sha256.toLowerCase(), + size: Number(found.size), + }; +} + +async function verifyFile(pathToFile: string, manifestPath: string): Promise { + const expected = manifestEntry(manifestPath); + if (!expected) return true; + const bytes = await readFile(pathToFile); + if (Number.isFinite(expected.size) && expected.size > 0 && bytes.byteLength !== expected.size) { + return false; + } + const actual = await sha256Hex(pathToFile); + return actual === expected.sha256; +} + +async function ensureLicense(): Promise { + await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); + if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { + throw new Error('PDF layout model license checksum verification failed'); + } +} + +async function ensureModelInternal(): Promise { + try { + await access(MODEL_PATH); + await access(MODEL_DATA_PATH); + await access(MODEL_CONFIG_PATH); + await access(MODEL_PREPROCESSOR_PATH); + if ( + await verifyFile(MODEL_PATH, 'model.onnx') + && await verifyFile(MODEL_DATA_PATH, 'model.onnx.data') + && await verifyFile(MODEL_CONFIG_PATH, 'config.json') + && await verifyFile(MODEL_PREPROCESSOR_PATH, 'preprocessor_config.json') + ) { + await ensureLicense(); + return MODEL_PATH; + } + } catch { + // continue + } + + await mkdir(MODEL_DIR, { recursive: true }); + const modelTmpPath = `${MODEL_PATH}.tmp`; + const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`; + const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`; + const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`; + const modelBaseUrl = process.env[PDF_LAYOUT_MODEL_BASE_URL_ENV]?.trim() + || DEFAULT_MODEL_BASE_URL; + const modelUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx'); + const modelDataUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx.data'); + const configUrl = joinModelUrl(modelBaseUrl, 'config.json'); + const preprocessorUrl = joinModelUrl(modelBaseUrl, 'preprocessor_config.json'); + + await downloadToFile(modelUrl, modelTmpPath); + if (!(await verifyFile(modelTmpPath, 'model.onnx'))) { + await unlink(modelTmpPath).catch(() => undefined); + throw new Error('PDF layout model checksum verification failed'); + } + await downloadToFile(modelDataUrl, modelDataTmpPath); + if (!(await verifyFile(modelDataTmpPath, 'model.onnx.data'))) { + await unlink(modelDataTmpPath).catch(() => undefined); + throw new Error('PDF layout model external data checksum verification failed'); + } + await downloadToFile(configUrl, configTmpPath); + if (!(await verifyFile(configTmpPath, 'config.json'))) { + await unlink(configTmpPath).catch(() => undefined); + throw new Error('PDF layout model config checksum verification failed'); + } + await downloadToFile(preprocessorUrl, preprocessorTmpPath); + if (!(await verifyFile(preprocessorTmpPath, 'preprocessor_config.json'))) { + await unlink(preprocessorTmpPath).catch(() => undefined); + throw new Error('PDF layout model preprocessor checksum verification failed'); + } + + await rename(modelTmpPath, MODEL_PATH); + await rename(modelDataTmpPath, MODEL_DATA_PATH); + await rename(configTmpPath, MODEL_CONFIG_PATH); + await rename(preprocessorTmpPath, MODEL_PREPROCESSOR_PATH); + await ensureLicense(); + return MODEL_PATH; +} + +export async function ensureModel(): Promise { + if (inflight) return inflight; + inflight = ensureModelInternal().finally(() => { + inflight = null; + }); + return inflight; +} diff --git a/compute/core/src/pdf-layout/mergeTextWithRegions.ts b/compute/core/src/pdf-layout/mergeTextWithRegions.ts new file mode 100644 index 0000000..aeff9ab --- /dev/null +++ b/compute/core/src/pdf-layout/mergeTextWithRegions.ts @@ -0,0 +1,153 @@ +import type { LayoutRegion, PdfTextItem } from './types'; + +const NON_TEXT_REGION_LABELS = new Set(['chart', 'image', 'table', 'seal']); +const TEXT_ASSIGNABLE_LABELS = new Set([ + 'abstract', + 'algorithm', + 'aside_text', + 'content', + 'doc_title', + 'figure_title', + 'footer', + 'footnote', + 'formula_number', + 'header', + 'number', + 'paragraph_title', + 'reference', + 'reference_content', + 'text', + 'vision_footnote', + 'formula', +]); + +function centroid(item: PdfTextItem): { x: number; y: number } { + return { + x: item.x + item.width / 2, + y: item.y + item.height / 2, + }; +} + +function contains(region: LayoutRegion, item: PdfTextItem): boolean { + const c = centroid(item); + return c.x >= region.bbox[0] && c.x <= region.bbox[2] && c.y >= region.bbox[1] && c.y <= region.bbox[3]; +} + +function sortReadingOrder(items: PdfTextItem[]): PdfTextItem[] { + const tolerance = 2; + return [...items].sort((a, b) => { + if (Math.abs(a.y - b.y) <= tolerance) return a.x - b.x; + return a.y - b.y; + }); +} + +function joinText(items: PdfTextItem[]): string { + let out = ''; + let prev: PdfTextItem | null = null; + for (const item of items) { + if (!prev) { + out += item.text; + prev = item; + continue; + } + const prevEndX = prev.x + prev.width; + const gap = item.x - prevEndX; + const lineJump = item.y - prev.y; + const lineBreak = lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6); + const avgCharWidth = item.width / Math.max(1, item.text.length); + const needsSpace = lineBreak || gap > Math.max(avgCharWidth * 0.3, 2); + out += needsSpace ? ` ${item.text}` : item.text; + prev = item; + } + return out.replace(/\s+/g, ' ').trim(); +} + +function regionArea(region: LayoutRegion): number { + return Math.max(1, (region.bbox[2] - region.bbox[0]) * (region.bbox[3] - region.bbox[1])); +} + +function regionScore(region: LayoutRegion): number { + return Number.isFinite(region.confidence) ? Number(region.confidence) : 0; +} + +export interface RegionTextBlock { + region: LayoutRegion; + text: string; + items: PdfTextItem[]; + sourceOrder: number; +} + +export function mergeTextWithRegions(regions: LayoutRegion[], textItems: PdfTextItem[]): RegionTextBlock[] { + const sourceIndex = new Map(); + for (let i = 0; i < textItems.length; i += 1) { + sourceIndex.set(textItems[i]!, i); + } + + const chunkSourceOrder = (items: PdfTextItem[]): number => { + let min = Number.POSITIVE_INFINITY; + for (const item of items) { + const index = sourceIndex.get(item); + if (typeof index === 'number' && index < min) min = index; + } + return Number.isFinite(min) ? min : Number.MAX_SAFE_INTEGER; + }; + + const assignableRegions = regions + .map((region, index) => ({ region, index })) + .filter(({ region }) => TEXT_ASSIGNABLE_LABELS.has(region.label)); + const assignedByRegion = new Map(); + + for (const item of textItems) { + const candidates = assignableRegions.filter(({ region }) => contains(region, item)); + if (candidates.length === 0) continue; + + candidates.sort((a, b) => { + const scoreDelta = regionScore(b.region) - regionScore(a.region); + if (Math.abs(scoreDelta) > 1e-6) return scoreDelta; + return regionArea(a.region) - regionArea(b.region); + }); + + const winner = candidates[0]; + const list = assignedByRegion.get(winner.index) ?? []; + list.push(item); + assignedByRegion.set(winner.index, list); + } + + const out: RegionTextBlock[] = []; + + for (const [regionIndex, assignedItems] of assignedByRegion.entries()) { + const region = regions[regionIndex]; + if (!region) continue; + if (assignedItems.length === 0) continue; + const ordered = sortReadingOrder(assignedItems); + const text = joinText(ordered); + if (!text) continue; + + out.push({ + region, + text, + items: ordered, + sourceOrder: chunkSourceOrder(ordered), + }); + } + + for (const region of regions) { + if (!NON_TEXT_REGION_LABELS.has(region.label)) continue; + out.push({ + region, + text: '', + items: [], + sourceOrder: Number.MAX_SAFE_INTEGER, + }); + } + + out.sort((a, b) => { + if (a.sourceOrder !== b.sourceOrder) return a.sourceOrder - b.sourceOrder; + const ay = a.region.bbox[1]; + const by = b.region.bbox[1]; + if (Math.abs(ay - by) <= 2) return a.region.bbox[0] - b.region.bbox[0]; + return ay - by; + }); + + return out; +} diff --git a/compute/core/src/pdf-layout/model/LICENSE.txt b/compute/core/src/pdf-layout/model/LICENSE.txt new file mode 100644 index 0000000..cb60fe1 --- /dev/null +++ b/compute/core/src/pdf-layout/model/LICENSE.txt @@ -0,0 +1,3 @@ +PP-DocLayoutV3 ONNX model assets are distributed by the model publisher under Apache-2.0. +See upstream for the authoritative license and terms: +https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX diff --git a/compute/core/src/pdf-layout/model/manifest.json b/compute/core/src/pdf-layout/model/manifest.json new file mode 100644 index 0000000..9f4807d --- /dev/null +++ b/compute/core/src/pdf-layout/model/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "pp-doclayoutv3", + "version": "Bei0001/PP-DocLayoutV3-ONNX@main", + "files": [ + { + "path": "model.onnx", + "sha256": "c0721928ff08741bb208ebed539c77170db5234a68cb7e546e6cc9bc172a695b", + "size": 5088167 + }, + { + "path": "model.onnx.data", + "sha256": "34df3e4b79d7bbbf82abce1b4f3cde3d540fa57ad42ec8905c352b97c408d437", + "size": 136774480 + }, + { + "path": "config.json", + "sha256": "3cf834b91d23a756b1519bce4db42c09e852f3e35c35092dd5a3e253a50c071a", + "size": 2460 + }, + { + "path": "preprocessor_config.json", + "sha256": "519fe0187a43a1ca429e3ad8317bab8700f0d5e8fb3a6e3a0a413ffac078ba42", + "size": 575 + }, + { + "path": "LICENSE.txt", + "sha256": "578a6ba6f86b0692a8f719843f575a3eebf4705768ac5c37d149f441208f601f", + "size": 195 + } + ] +} diff --git a/compute/core/src/pdf-layout/parsePdf.ts b/compute/core/src/pdf-layout/parsePdf.ts new file mode 100644 index 0000000..74c9dfe --- /dev/null +++ b/compute/core/src/pdf-layout/parsePdf.ts @@ -0,0 +1,165 @@ +import path from 'path'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf'; +import type { PdfTextItem } from './types'; +import { ensureModel } from './ensureModel'; +import { runLayoutModel } from './runLayoutModel'; +import { mergeTextWithRegions } from './mergeTextWithRegions'; +import { stitchCrossPageBlocks } from './stitchCrossPageBlocks'; +import { renderPage } from './renderPage'; + +interface ParsePdfInput { + documentId: string; + pdfBytes: ArrayBuffer; +} + +const LAYOUT_RENDER_SCALE = 1.5; + +export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] { + return items + .filter((item) => { + if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false; + const transform = item.transform; + if (!Array.isArray(transform) || transform.length < 6) return false; + + // Reject heavily skewed/rotated text runs (e.g. vertical margin labels + // such as arXiv metadata) so they do not get merged into body blocks. + const skewX = Number(transform[1] ?? 0); + const skewY = Number(transform[2] ?? 0); + if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; + + return true; + }) + .map((item) => { + const x = Number(item.transform[4] ?? 0); + const width = Math.max(0, Number(item.width ?? 0)); + const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1))); + const baselineY = Number(item.transform[5] ?? 0); + // pdf.js text transforms are in PDF user-space (origin bottom-left). + // Normalize into top-left page coordinates to match rendered image/model boxes. + const y = Math.max(0, pageHeight - baselineY - height); + return { + text: item.str, + x, + y, + width, + height, + }; + }); +} + +export async function parsePdf(input: ParsePdfInput): Promise { + await ensureModel(); + + // Keep independent byte copies for text extraction and page rendering. pdf.js + // can detach buffers passed to getDocument(). + const pdfBytesForText = new Uint8Array(input.pdfBytes).slice(); + const pdfBytesForRender = new Uint8Array(input.pdfBytes).slice(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: pdfBytesForText, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + }); + const pdf = await loadingTask.promise; + + try { + const pages: ParsedPdfPage[] = []; + let nextBlockId = 1; + let sawText = false; + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1.0 }); + const textContent = await page.getTextContent(); + const textItems = normalizeTextItemsForLayout( + textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), + viewport.height, + ); + + if (textItems.length > 0) sawText = true; + + const rendered = await renderPage({ + pdfBytes: pdfBytesForRender.buffer.slice( + pdfBytesForRender.byteOffset, + pdfBytesForRender.byteOffset + pdfBytesForRender.byteLength, + ), + pageNumber, + scale: LAYOUT_RENDER_SCALE, + }); + const scaleX = rendered.width / Math.max(1, viewport.width); + const scaleY = rendered.height / Math.max(1, viewport.height); + const layoutTextItems = textItems.map((item) => ({ + ...item, + x: item.x * scaleX, + y: item.y * scaleY, + width: item.width * scaleX, + height: item.height * scaleY, + })); + const regions = await runLayoutModel({ + pageWidth: rendered.width, + pageHeight: rendered.height, + textItems: layoutTextItems, + pageImage: rendered.image, + }); + const merged = mergeTextWithRegions(regions, layoutTextItems); + if (textItems.length > 0 && merged.length === 0) { + throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`); + } + + const blocks = merged + .map((entry, readingOrder) => ({ + id: `b${String(nextBlockId++).padStart(4, '0')}`, + kind: entry.region.label, + fragments: [{ + page: pageNumber, + bbox: [ + entry.region.bbox[0] / scaleX, + entry.region.bbox[1] / scaleY, + entry.region.bbox[2] / scaleX, + entry.region.bbox[3] / scaleY, + ] as [number, number, number, number], + text: entry.text, + readingOrder, + ...(typeof entry.region.confidence === 'number' ? { modelConfidence: entry.region.confidence } : {}), + }], + text: entry.text, + })); + + pages.push({ + pageNumber, + width: viewport.width, + height: viewport.height, + blocks, + }); + } + + if (!sawText) { + throw new Error('no-text-layer'); + } + + const doc: ParsedPdfDocument = { + schemaVersion: 1, + documentId: input.documentId, + parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69', + parsedAt: Date.now(), + pages, + }; + + return stitchCrossPageBlocks(doc); + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/compute/core/src/pdf-layout/renderPage.ts b/compute/core/src/pdf-layout/renderPage.ts new file mode 100644 index 0000000..e68fb66 --- /dev/null +++ b/compute/core/src/pdf-layout/renderPage.ts @@ -0,0 +1,162 @@ +import path from 'path'; +import type { Canvas } from '@napi-rs/canvas'; + +type CanvasRuntime = { + DOMMatrixCtor: unknown; + Path2DCtor: unknown; + createCanvasFn: (width: number, height: number) => Canvas; +}; + +let canvasRuntimePromise: Promise | null = null; + +async function loadCanvasRuntime(): Promise { + if (!canvasRuntimePromise) { + canvasRuntimePromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => Canvas) + | undefined; + const DOMMatrixCtor = namespace.DOMMatrix ?? fallback.DOMMatrix; + const Path2DCtor = namespace.Path2D ?? fallback.Path2D; + + if (typeof createCanvasFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas export (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + if (!DOMMatrixCtor || !Path2DCtor) { + throw new Error( + `Canvas runtime missing DOMMatrix/Path2D exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { + DOMMatrixCtor, + Path2DCtor, + createCanvasFn, + }; + })(); + } + return canvasRuntimePromise; +} + +function ensureNodeCanvasGlobals(runtime: CanvasRuntime): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = runtime.DOMMatrixCtor; + if (typeof g.Path2D === 'undefined') g.Path2D = runtime.Path2DCtor; +} + +interface RenderInput { + pdfBytes: ArrayBuffer; + pageNumber: number; + scale?: number; + targetWidth?: number; + format?: 'png' | 'jpeg'; + jpegQuality?: number; +} + +function createPdfjsCanvasFactory(runtime: CanvasRuntime) { + return class OpenReaderCanvasFactory { + create(width: number, height: number) { + const canvas = runtime.createCanvasFn(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height))); + return { + canvas, + context: canvas.getContext('2d') as unknown as CanvasRenderingContext2D, + }; + } + + reset(target: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number): void { + target.canvas.width = Math.max(1, Math.floor(width)); + target.canvas.height = Math.max(1, Math.floor(height)); + } + + destroy(target: { canvas: Canvas; context: CanvasRenderingContext2D }): void { + target.canvas.width = 0; + target.canvas.height = 0; + // @ts-expect-error pdf.js expects these nulled on destroy + target.canvas = null; + // @ts-expect-error pdf.js expects these nulled on destroy + target.context = null; + } + }; +} + +export async function renderPage({ + pdfBytes, + pageNumber, + scale = 1.5, + targetWidth, + format = 'png', + jpegQuality = 82, +}: RenderInput): Promise<{ + width: number; + height: number; + image: Buffer; + contentType: 'image/png' | 'image/jpeg'; +}> { + // pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so + // callers can safely reuse their original bytes across pages/calls. + const isolatedBytes = new Uint8Array(pdfBytes).slice(); + + const canvasRuntime = await loadCanvasRuntime(); + ensureNodeCanvasGlobals(canvasRuntime); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: isolatedBytes, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + // Ensure pdf.js transport uses our canvas backend in Node/Next runtime. + CanvasFactory: createPdfjsCanvasFactory(canvasRuntime), + }); + + const pdf = await loadingTask.promise; + try { + const page = await pdf.getPage(pageNumber); + const baseViewport = page.getViewport({ scale: 1.0 }); + const effectiveScale = typeof targetWidth === 'number' && Number.isFinite(targetWidth) && targetWidth > 0 + ? (Math.max(1, Math.round(targetWidth)) / Math.max(1, baseViewport.width)) + : scale; + const viewport = page.getViewport({ scale: effectiveScale }); + const width = Math.max(1, Math.floor(viewport.width)); + const height = Math.max(1, Math.floor(viewport.height)); + const canvas = canvasRuntime.createCanvasFn(width, height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + + const renderTask = page.render({ + canvasContext: ctx as unknown as CanvasRenderingContext2D, + viewport, + intent: 'display', + }); + await renderTask.promise; + const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png'; + const image = format === 'jpeg' + ? canvas.toBuffer('image/jpeg', jpegQuality) + : canvas.toBuffer('image/png'); + return { + width, + height, + image, + contentType, + }; + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/compute/core/src/pdf-layout/runLayoutModel.ts b/compute/core/src/pdf-layout/runLayoutModel.ts new file mode 100644 index 0000000..f7b80eb --- /dev/null +++ b/compute/core/src/pdf-layout/runLayoutModel.ts @@ -0,0 +1,331 @@ +import * as ort from 'onnxruntime-node'; +import { readFile } from 'fs/promises'; +import type { LayoutRegion, PdfTextItem } from './types'; +import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './ensureModel'; + +interface RunLayoutInput { + pageWidth: number; + pageHeight: number; + textItems: PdfTextItem[]; + pageImage: Buffer; +} + +const DEFAULT_INPUT_SIZE = 800; +const MIN_SCORE = 0.5; +const CLASS_MIN_SCORE: Partial> = { + header: 0.4, + footer: 0.4, + figure_title: 0.45, + footnote: 0.45, + vision_footnote: 0.45, +}; + +const LABEL_MAP: Record = { + // PP-DocLayoutV3 labels + abstract: 'abstract', + algorithm: 'algorithm', + aside_text: 'aside_text', + chart: 'chart', + content: 'content', + display_formula: 'formula', + doc_title: 'doc_title', + figure_title: 'figure_title', + footer: 'footer', + footer_image: 'footer', + footnote: 'footnote', + formula_number: 'formula_number', + header: 'header', + header_image: 'header', + image: 'image', + inline_formula: 'formula', + number: 'number', + paragraph_title: 'paragraph_title', + reference: 'reference', + reference_content: 'reference_content', + seal: 'seal', + table: 'table', + text: 'text', + vertical_text: 'text', + vision_footnote: 'vision_footnote', +}; + +const MIN_REGION_SIZE: Partial> = { + abstract: { minWidth: 24, minHeight: 14 }, + algorithm: { minWidth: 24, minHeight: 14 }, + aside_text: { minWidth: 24, minHeight: 14 }, + content: { minWidth: 24, minHeight: 14 }, + text: { minWidth: 24, minHeight: 14 }, + reference: { minWidth: 24, minHeight: 14 }, + reference_content: { minWidth: 24, minHeight: 14 }, + paragraph_title: { minWidth: 24, minHeight: 14 }, + doc_title: { minWidth: 24, minHeight: 14 }, + number: { minWidth: 18, minHeight: 12 }, + figure_title: { minWidth: 18, minHeight: 10 }, + footnote: { minWidth: 18, minHeight: 10 }, + vision_footnote: { minWidth: 18, minHeight: 10 }, + header: { minWidth: 18, minHeight: 10 }, + footer: { minWidth: 18, minHeight: 10 }, +}; + +interface ModelPreprocessor { + inputWidth: number; + inputHeight: number; + rescaleFactor: number; + mean: [number, number, number]; + std: [number, number, number]; +} + +let sessionPromise: Promise | null = null; +let idToLabelPromise: Promise> | null = null; +let preprocessorPromise: Promise | null = null; +let canvasFnsPromise: Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> | null = null; + +async function getCanvasFns(): Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> { + if (!canvasFnsPromise) { + canvasFnsPromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }) + | undefined; + const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as + | ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>) + | undefined; + + if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { createCanvasFn, loadImageFn }; + })(); + } + return canvasFnsPromise; +} + +async function getSession(): Promise { + if (!sessionPromise) { + sessionPromise = (async () => { + const modelPath = await ensureModel(); + return ort.InferenceSession.create(modelPath, { + executionProviders: ['cpu'], + graphOptimizationLevel: 'all', + }); + })(); + } + return sessionPromise; +} + +async function getIdToLabel(): Promise> { + if (!idToLabelPromise) { + idToLabelPromise = (async () => { + await ensureModel(); + const raw = await readFile(MODEL_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(raw) as { id2label?: Record }; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed.id2label ?? {})) { + const n = Number(key); + if (Number.isFinite(n)) out[n] = String(value ?? '').trim(); + } + return out; + })(); + } + return idToLabelPromise; +} + +async function getPreprocessor(): Promise { + if (!preprocessorPromise) { + preprocessorPromise = (async () => { + await ensureModel(); + const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8'); + const parsed = JSON.parse(raw) as { + size?: { width?: number; height?: number }; + rescale_factor?: number; + image_mean?: number[]; + image_std?: number[]; + }; + + const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE)); + const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE)); + const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255); + const mean = [ + Number(parsed.image_mean?.[0] ?? 0), + Number(parsed.image_mean?.[1] ?? 0), + Number(parsed.image_mean?.[2] ?? 0), + ] as [number, number, number]; + const std = [ + Number(parsed.image_std?.[0] ?? 1), + Number(parsed.image_std?.[1] ?? 1), + Number(parsed.image_std?.[2] ?? 1), + ] as [number, number, number]; + + return { + inputWidth, + inputHeight, + rescaleFactor, + mean, + std, + }; + })(); + } + return preprocessorPromise; +} + +function preprocessResized( + image: CanvasImageSource, + preprocessor: ModelPreprocessor, + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, +): ort.Tensor { + const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + + const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight); + const channelSize = preprocessor.inputWidth * preprocessor.inputHeight; + for (let y = 0; y < preprocessor.inputHeight; y += 1) { + for (let x = 0; x < preprocessor.inputWidth; x += 1) { + const pixelIndex = (y * preprocessor.inputWidth + x) * 4; + const idx = y * preprocessor.inputWidth + x; + const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor; + const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor; + const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor; + + chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]); + chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]); + chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]); + } + } + return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]); +} + +function clampBox( + bbox: [number, number, number, number], + pageWidth: number, + pageHeight: number, +): [number, number, number, number] | null { + const x0 = Math.max(0, Math.min(pageWidth, bbox[0])); + const y0 = Math.max(0, Math.min(pageHeight, bbox[1])); + const x1 = Math.max(0, Math.min(pageWidth, bbox[2])); + const y1 = Math.max(0, Math.min(pageHeight, bbox[3])); + if (x1 <= x0 || y1 <= y0) return null; + return [x0, y0, x1, y1]; +} + +function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } { + let maxLogit = Number.NEGATIVE_INFINITY; + let maxIndex = 0; + for (let i = 0; i < count; i += 1) { + const value = logits[offset + i]; + if (value > maxLogit) { + maxLogit = value; + maxIndex = i; + } + } + + let sum = 0; + for (let i = 0; i < count; i += 1) { + sum += Math.exp(logits[offset + i] - maxLogit); + } + + const score = sum > 0 ? (1 / sum) : 0; + return { index: maxIndex, score }; +} + +function normalizeModelLabel(rawLabel: string): string { + const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (normalized.endsWith('_image')) { + const base = normalized.slice(0, -'_image'.length); + if (base === 'header' || base === 'footer') return normalized; + } + // Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1). + return normalized.replace(/_\d+$/g, ''); +} + +export async function runLayoutModel(input: RunLayoutInput): Promise { + const { pageWidth, pageHeight, textItems, pageImage } = input; + if (!textItems.length) return []; + if (!pageImage || pageImage.byteLength === 0) { + throw new Error('layout-render-missing-page-image'); + } + + try { + const [session, idToLabel, preprocessor, canvasFns] = await Promise.all([ + getSession(), + getIdToLabel(), + getPreprocessor(), + getCanvasFns(), + ]); + + const decodedPageImage = await canvasFns.loadImageFn(pageImage); + const pixelValues = preprocessResized(decodedPageImage, preprocessor, canvasFns.createCanvasFn); + const output = await session.run({ pixel_values: pixelValues }); + + const logits = output.logits?.data as Float32Array | undefined; + const predBoxes = output.pred_boxes?.data as Float32Array | undefined; + if (!logits || !predBoxes) return []; + + const numQueries = Math.floor(predBoxes.length / 4); + if (numQueries <= 0) return []; + const classCount = Math.floor(logits.length / numQueries); + if (classCount <= 0) return []; + + const regions: LayoutRegion[] = []; + + for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) { + const cls = softmaxMax(logits, queryIdx * classCount, classCount); + const rawLabel = idToLabel[cls.index]; + if (!rawLabel) continue; + const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)]; + if (!mapped) continue; + + const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE; + if (!Number.isFinite(cls.score) || cls.score < minScore) continue; + + const cx = predBoxes[queryIdx * 4 + 0] * pageWidth; + const cy = predBoxes[queryIdx * 4 + 1] * pageHeight; + const w = predBoxes[queryIdx * 4 + 2] * pageWidth; + const h = predBoxes[queryIdx * 4 + 3] * pageHeight; + const rawBox: [number, number, number, number] = [ + cx - w / 2, + cy - h / 2, + cx + w / 2, + cy + h / 2, + ]; + + const clamped = clampBox(rawBox, pageWidth, pageHeight); + if (!clamped) continue; + + const sizeRule = MIN_REGION_SIZE[mapped]; + if (sizeRule) { + const width = clamped[2] - clamped[0]; + const height = clamped[3] - clamped[1]; + if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue; + } + + regions.push({ + bbox: clamped, + label: mapped, + confidence: cls.score, + }); + } + + return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + } catch (error) { + throw new Error( + `layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/compute/core/src/pdf-layout/stitchCrossPageBlocks.ts b/compute/core/src/pdf-layout/stitchCrossPageBlocks.ts new file mode 100644 index 0000000..98a3d77 --- /dev/null +++ b/compute/core/src/pdf-layout/stitchCrossPageBlocks.ts @@ -0,0 +1,144 @@ +import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf'; + +const STITCHABLE_KINDS = new Set([ + 'text', + 'content', + 'reference_content', + 'aside_text', + 'abstract', + 'algorithm', + 'reference', +]); + +function stripTrailingClosers(text: string): string { + return text.trim().replace(/[\"'”’\]\)]+$/g, ''); +} + +function isSentenceTerminal(text: string): boolean { + return /[.!?]$/.test(stripTrailingClosers(text)); +} + +function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { + if (!STITCHABLE_KINDS.has(a.kind)) return false; + if (a.kind !== b.kind) return false; + if (isSentenceTerminal(a.text)) return false; + const next = b.text.trim(); + if (!next) return false; + if (/^[A-Z]/.test(next)) return false; + return true; +} + +function splitHeadContinuation(text: string): { continuation: string; remainder: string } { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return { continuation: '', remainder: '' }; + + const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']); + const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?'; + + for (let i = 0; i < normalized.length; i += 1) { + const ch = normalized[i]; + if (!isTerminal(ch)) continue; + + const prev = i > 0 ? normalized[i - 1] : ''; + const next = i + 1 < normalized.length ? normalized[i + 1] : ''; + if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue; + + let cut = i + 1; + while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1; + + const after = cut < normalized.length ? normalized[cut] : ''; + if (!after || /\s/.test(after) || /[A-Z]/.test(after)) { + return { + continuation: normalized.slice(0, cut).trim(), + remainder: normalized.slice(cut).trim(), + }; + } + } + + return { + continuation: normalized, + remainder: '', + }; +} + +const HARD_BOUNDARY_KINDS = new Set([ + 'paragraph_title', + 'doc_title', +]); + +function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (STITCHABLE_KINDS.has(block.kind)) return i; + } + return -1; +} + +function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (STITCHABLE_KINDS.has(block.kind)) return i; + } + return -1; +} + +function hasHardBoundaryBetween( + pageBlocks: ParsedPdfBlock[], + startInclusive: number, + endExclusive: number, +): boolean { + for (let i = startInclusive; i < endExclusive; i += 1) { + const block = pageBlocks[i]; + if (block && HARD_BOUNDARY_KINDS.has(block.kind)) return true; + } + return false; +} + +export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument { + const pages = doc.pages.map((page) => ({ ...page, blocks: page.blocks.map((b) => ({ ...b, fragments: b.fragments.map((f) => ({ ...f })) })) })); + + for (let i = 0; i < pages.length - 1; i += 1) { + const page = pages[i]; + const next = pages[i + 1]; + const tailIndex = findTailCandidateIndex(page.blocks); + const headIndex = findHeadCandidateIndex(next.blocks); + if (tailIndex < 0 || headIndex < 0) continue; + + if (hasHardBoundaryBetween(page.blocks, tailIndex + 1, page.blocks.length)) continue; + if (hasHardBoundaryBetween(next.blocks, 0, headIndex)) continue; + + const tail = page.blocks[tailIndex]; + const head = next.blocks[headIndex]; + if (!tail || !head) continue; + if (!canStitch(tail, head)) continue; + + const { continuation, remainder } = splitHeadContinuation(head.text); + if (!continuation) continue; + + const continuationFragment = head.fragments[0] + ? { ...head.fragments[0], text: continuation } + : null; + + if (continuationFragment) { + tail.fragments.push(continuationFragment); + } + tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim(); + + if (!remainder) { + next.blocks.splice(headIndex, 1); + continue; + } + + head.text = remainder; + if (head.fragments[0]) { + head.fragments[0].text = remainder; + } + } + + return { + ...doc, + pages, + }; +} diff --git a/compute/core/src/pdf-layout/types.ts b/compute/core/src/pdf-layout/types.ts new file mode 100644 index 0000000..38089a6 --- /dev/null +++ b/compute/core/src/pdf-layout/types.ts @@ -0,0 +1,15 @@ +import type { ParsedPdfBlockKind } from '../types/parsed-pdf'; + +export interface PdfTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LayoutRegion { + bbox: [number, number, number, number]; + label: ParsedPdfBlockKind; + confidence?: number; +} diff --git a/compute/core/src/runtime/docstore.ts b/compute/core/src/runtime/docstore.ts new file mode 100644 index 0000000..4e93930 --- /dev/null +++ b/compute/core/src/runtime/docstore.ts @@ -0,0 +1,7 @@ +import path from 'path'; + +export const DOCSTORE_DIR = process.env.COMPUTE_DOCSTORE_DIR?.trim() || path.join(process.cwd(), 'docstore'); + +export function getDocstoreDir(): string { + return DOCSTORE_DIR; +} diff --git a/compute/core/src/runtime/ffmpeg.ts b/compute/core/src/runtime/ffmpeg.ts new file mode 100644 index 0000000..b4ed669 --- /dev/null +++ b/compute/core/src/runtime/ffmpeg.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs'; +import ffmpegStatic from 'ffmpeg-static'; + +function normalizePath(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string { + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + if (!bundledValue) { + throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`); + } + + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + + return bundledValue; +} + +export function getFFmpegPath(): string { + return resolveBinary( + normalizePath(process.env.FFMPEG_BIN), + normalizePath(ffmpegStatic), + 'FFMPEG_BIN', + 'ffmpeg-static', + ); +} diff --git a/compute/core/src/types/parsed-pdf.ts b/compute/core/src/types/parsed-pdf.ts new file mode 100644 index 0000000..137eec4 --- /dev/null +++ b/compute/core/src/types/parsed-pdf.ts @@ -0,0 +1,53 @@ +export type ParsedPdfBlockKind = + | 'abstract' + | 'algorithm' + | 'aside_text' + | 'chart' + | 'content' + | 'formula' + | 'doc_title' + | 'figure_title' + | 'footer' + | 'footnote' + | 'formula_number' + | 'header' + | 'image' + | 'number' + | 'paragraph_title' + | 'reference' + | 'reference_content' + | 'seal' + | 'table' + | 'text' + | 'vision_footnote'; + +export interface ParsedPdfBlockFragment { + page: number; + bbox: [number, number, number, number]; + text: string; + readingOrder: number; + modelConfidence?: number; +} + +export interface ParsedPdfBlock { + id: string; + kind: ParsedPdfBlockKind; + fragments: ParsedPdfBlockFragment[]; + text: string; + parentSectionId?: string; +} + +export interface ParsedPdfPage { + pageNumber: number; + width: number; + height: number; + blocks: ParsedPdfBlock[]; +} + +export interface ParsedPdfDocument { + schemaVersion: 1; + documentId: string; + parserVersion: string; + parsedAt: number; + pages: ParsedPdfPage[]; +} diff --git a/compute/core/src/types/tts.ts b/compute/core/src/types/tts.ts new file mode 100644 index 0000000..73db759 --- /dev/null +++ b/compute/core/src/types/tts.ts @@ -0,0 +1,16 @@ +export type TTSAudioBuffer = ArrayBuffer; +export type TTSAudioBytes = number[]; + +export interface TTSSentenceWord { + text: string; + startSec: number; + endSec: number; + charStart: number; + charEnd: number; +} + +export interface TTSSentenceAlignment { + sentence: string; + sentenceIndex: number; + words: TTSSentenceWord[]; +} diff --git a/compute/core/src/whisper/alignment-mapping.ts b/compute/core/src/whisper/alignment-mapping.ts new file mode 100644 index 0000000..b26ecc8 --- /dev/null +++ b/compute/core/src/whisper/alignment-mapping.ts @@ -0,0 +1,54 @@ +import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts'; + +function preprocessSentenceForAudio(text: string): string { + return text + .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') + .replace(/(\w+)-\s+(\w+)/g, '$1$2') + .replace(/\*/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export interface WhisperWord { + start: number; + end: number; + word: string; +} + +export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment { + const normalizedSentence = preprocessSentenceForAudio(sentence); + const lowerSentence = normalizedSentence.toLowerCase(); + let cursor = 0; + + const alignedWords: TTSSentenceWord[] = words.map((w) => { + const token = w.word.trim(); + if (!token) { + return { + text: '', + startSec: w.start, + endSec: w.end, + charStart: cursor, + charEnd: cursor, + }; + } + + const idx = lowerSentence.indexOf(token.toLowerCase(), cursor); + const start = idx >= 0 ? idx : cursor; + const end = Math.min(normalizedSentence.length, start + token.length); + cursor = Math.max(cursor, end); + + return { + text: token, + startSec: w.start, + endSec: w.end, + charStart: start, + charEnd: end, + }; + }).filter((word) => word.text.length > 0); + + return { + sentence, + sentenceIndex: 0, + words: alignedWords, + }; +} diff --git a/compute/core/src/whisper/alignment.ts b/compute/core/src/whisper/alignment.ts new file mode 100644 index 0000000..7f6607f --- /dev/null +++ b/compute/core/src/whisper/alignment.ts @@ -0,0 +1,1012 @@ +import { createHash, randomUUID } from 'crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import * as ort from 'onnxruntime-node'; +import { Tokenizer } from '@huggingface/tokenizers'; +import JSZip from 'jszip'; +import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts'; +import { getFFmpegPath } from '../runtime/ffmpeg'; +import { + mapWordsToSentenceOffsets, + type WhisperWord, +} from './alignment-mapping'; +import { buildGoertzelCoefficients, goertzelPower } from './spectral'; +import { + buildWordsFromTimestampedTokens, + extractTokenStartTimestamps, +} from './token-timestamps'; +import { + ensureWhisperModel, + WHISPER_CONFIG_PATH, + WHISPER_GENERATION_CONFIG_PATH, + WHISPER_TOKENIZER_CONFIG_PATH, + WHISPER_TOKENIZER_PATH, + WHISPER_ENCODER_MODEL_PATH, + WHISPER_DECODER_MERGED_MODEL_PATH, + WHISPER_DECODER_WITH_PAST_MODEL_PATH, +} from './ensureModel'; + +interface WhisperAlignmentOptions { + lang?: string; + textHint?: string; +} + +export interface WhisperRequestBody { + text: string; + audio: TTSAudioBytes; + lang?: string; +} + +interface WhisperRuntime { + encoder: ort.InferenceSession; + decoderMerged: ort.InferenceSession; + decoderWithPast: ort.InferenceSession; + tokenizer: Tokenizer; + promptStartToken: number; + defaultLanguageToken: number; + transcribeToken: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; + maxDecodeSteps: number; + suppressTokens: Set; + beginSuppressTokens: Set; + alignmentHeads: Array<[number, number]>; + prefillFetches: string[]; + stepFetches: string[]; +} + +type WhisperAlignmentState = { + alignmentCache: Map; + alignmentInFlight: Map>; + runtimePromise: Promise | null; + alignMutex: Promise; + pendingAlignments: number; + officialMelFilters: Float32Array[] | null; + emptyPastFeedsTemplate: Record | null; +}; + +const WHISPER_ALIGNMENT_STATE_KEY = '__openreaderWhisperAlignmentStateV1'; +const g = globalThis as typeof globalThis & Record; +const state = (() => { + const existing = g[WHISPER_ALIGNMENT_STATE_KEY] as WhisperAlignmentState | undefined; + if (existing) return existing; + const created: WhisperAlignmentState = { + alignmentCache: new Map(), + alignmentInFlight: new Map>(), + runtimePromise: null, + alignMutex: Promise.resolve(), + pendingAlignments: 0, + officialMelFilters: null, + emptyPastFeedsTemplate: null, + }; + g[WHISPER_ALIGNMENT_STATE_KEY] = created; + return created; +})(); +const alignmentCache = state.alignmentCache; +const alignmentInFlight = state.alignmentInFlight; +const ALIGNMENT_CACHE_MAX_ENTRIES = 256; +const MAX_DECODE_STEPS_CAP = 128; +const ALIGNMENT_TIMEOUT_MS = 25000; +const FFMPEG_DECODE_TIMEOUT_MS = 10000; + +const SAMPLE_RATE = 16000; +const N_FFT = 400; +const HOP_LENGTH = 160; +const CHUNK_LENGTH_SECONDS = 30; +const N_SAMPLES = CHUNK_LENGTH_SECONDS * SAMPLE_RATE; +const N_FRAMES = N_SAMPLES / HOP_LENGTH; +const N_MELS = 80; +const WHISPER_NUM_HEADS = 8; +const WHISPER_HEAD_DIM = 64; +const WHISPER_NUM_LAYERS = 6; +const MEL_FILTER_BINS = (N_FFT / 2) + 1; + +const hannWindow = buildHannWindow(N_FFT); +const goertzelCoefficients = buildGoertzelCoefficients(MEL_FILTER_BINS, N_FFT); + +const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); +const MEL_FILTERS_NPZ_PATH = join(MODULE_DIR, 'model', 'mel_filters.npz'); + +function buildHannWindow(length: number): Float32Array { + const window = new Float32Array(length); + for (let i = 0; i < length; i += 1) { + window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / length); + } + return window; +} + +function parseNpyFloat32(bytes: Uint8Array): { shape: number[]; data: Float32Array } { + if (bytes.length < 12) { + throw new Error('Invalid NPY payload: too short'); + } + const magic = String.fromCharCode(...bytes.slice(0, 6)); + if (magic !== '\u0093NUMPY') { + throw new Error('Invalid NPY payload: missing magic header'); + } + + const major = bytes[6]; + const headerLength = major <= 1 + ? new DataView(bytes.buffer, bytes.byteOffset + 8, 2).getUint16(0, true) + : new DataView(bytes.buffer, bytes.byteOffset + 8, 4).getUint32(0, true); + const headerOffset = major <= 1 ? 10 : 12; + const header = Buffer.from(bytes.slice(headerOffset, headerOffset + headerLength)).toString('latin1'); + + const descrMatch = header.match(/'descr':\s*'([^']+)'/); + if (!descrMatch || descrMatch[1] !== ' token.trim()) + .filter(Boolean) + .map((token) => Number(token)) + .filter((n) => Number.isFinite(n) && n > 0); + + const dataOffset = headerOffset + headerLength; + const dataBytes = bytes.slice(dataOffset); + const totalFloats = Math.floor(dataBytes.byteLength / 4); + const data = new Float32Array(totalFloats); + const view = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength); + for (let i = 0; i < totalFloats; i += 1) { + data[i] = view.getFloat32(i * 4, true); + } + + return { shape, data }; +} + +async function loadOfficialMelFilters(): Promise { + if (state.officialMelFilters) return state.officialMelFilters; + + const npzBytes = await readFile(MEL_FILTERS_NPZ_PATH); + const zip = await JSZip.loadAsync(npzBytes); + const mel80 = zip.file('mel_80.npy'); + if (!mel80) { + throw new Error('OpenAI mel filter asset is missing mel_80.npy'); + } + + const raw = await mel80.async('uint8array'); + const parsed = parseNpyFloat32(raw); + const [rows, cols] = parsed.shape; + if (rows !== N_MELS || cols !== MEL_FILTER_BINS) { + throw new Error(`Unexpected mel filter shape: [${rows}, ${cols}]`); + } + + const filters: Float32Array[] = []; + for (let row = 0; row < rows; row += 1) { + const start = row * cols; + filters.push(parsed.data.slice(start, start + cols)); + } + + state.officialMelFilters = filters; + return filters; +} + +function pcm16ToFloat32(buffer: Buffer): Float32Array { + const view = new Int16Array(buffer.buffer, buffer.byteOffset, Math.floor(buffer.byteLength / 2)); + const out = new Float32Array(view.length); + for (let i = 0; i < view.length; i += 1) { + out[i] = view[i] / 32768; + } + return out; +} + +function padOrTrimAudio(samples: Float32Array): Float32Array { + if (samples.length === N_SAMPLES) return samples; + if (samples.length > N_SAMPLES) return samples.subarray(0, N_SAMPLES); + + const padded = new Float32Array(N_SAMPLES); + padded.set(samples, 0); + return padded; +} + +function reflectPad(audio: Float32Array, pad: number): Float32Array { + const out = new Float32Array(audio.length + (2 * pad)); + out.set(audio, pad); + + // Match PyTorch reflect padding (exclude edge sample). + for (let i = 0; i < pad; i += 1) { + out[pad - 1 - i] = audio[Math.min(audio.length - 1, i + 1)]; + out[pad + audio.length + i] = audio[Math.max(0, audio.length - 2 - i)]; + } + + return out; +} + +function computeLogMelSpectrogram(audioSamples: Float32Array): ort.Tensor { + if (!state.officialMelFilters) { + throw new Error('Whisper mel filters not loaded'); + } + + const paddedAudio = reflectPad(audioSamples, N_FFT / 2); + const stftFrames = N_FRAMES + 1; + const frameCount = N_FRAMES; + const freqBins = MEL_FILTER_BINS; + + const melSpec = Array.from({ length: N_MELS }, () => new Float32Array(frameCount)); + const frame = new Float32Array(N_FFT); + const power = new Float32Array(freqBins); + + for (let frameIndex = 0; frameIndex < stftFrames; frameIndex += 1) { + const offset = frameIndex * HOP_LENGTH; + + for (let i = 0; i < N_FFT; i += 1) { + frame[i] = (paddedAudio[offset + i] ?? 0) * hannWindow[i]; + } + + for (let k = 0; k < freqBins; k += 1) { + power[k] = goertzelPower(frame, goertzelCoefficients[k]); + } + + if (frameIndex === stftFrames - 1) { + continue; + } + + for (let melIndex = 0; melIndex < N_MELS; melIndex += 1) { + const filter = state.officialMelFilters[melIndex]; + let total = 0; + for (let k = 0; k < freqBins; k += 1) { + total += filter[k] * power[k]; + } + melSpec[melIndex][frameIndex] = total; + } + } + + // Whisper normalization from openai/whisper/audio.py + let globalMaxLog = Number.NEGATIVE_INFINITY; + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const logVal = Math.log10(Math.max(1e-10, melSpec[i][j])); + if (logVal > globalMaxLog) globalMaxLog = logVal; + melSpec[i][j] = logVal; + } + } + + const floorVal = globalMaxLog - 8.0; + const flattened = new Float32Array(1 * N_MELS * frameCount); + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const clamped = Math.max(melSpec[i][j], floorVal); + flattened[(i * frameCount) + j] = (clamped + 4.0) / 4.0; + } + } + + return new ort.Tensor('float32', flattened, [1, N_MELS, frameCount]); +} + +async function decodeToPcm16(inputPath: string, outputPath: string): Promise { + await new Promise((resolve, reject) => { + const ffmpeg = spawn(getFFmpegPath(), [ + '-y', + '-i', + inputPath, + '-f', + 's16le', + '-ar', + String(SAMPLE_RATE), + '-ac', + '1', + outputPath, + ]); + + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + ffmpeg.kill('SIGKILL'); + }, FFMPEG_DECODE_TIMEOUT_MS); + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ffmpeg.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + + ffmpeg.on('close', (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`ffmpeg decode timed out after ${FFMPEG_DECODE_TIMEOUT_MS}ms`)); + return; + } + if (code === 0) { + resolve(); + } else { + reject(new Error(`ffmpeg decode failed with code ${code}: ${stderr}`)); + } + }); + }); +} + +function parseLanguageCode(lang?: string): string | null { + if (!lang) return null; + const trimmed = lang.trim().toLowerCase(); + if (!trimmed) return null; + if (trimmed.includes('-')) return trimmed.split('-')[0] || null; + if (trimmed.includes('_')) return trimmed.split('_')[0] || null; + return trimmed; +} + +function tensorFromInt64(values: number[]): ort.Tensor { + return new ort.Tensor('int64', BigInt64Array.from(values.map((v) => BigInt(v))), [1, values.length]); +} + +function disposeTensor(tensor: ort.Tensor | undefined | null): void { + if (!tensor) return; + try { + tensor.dispose(); + } catch { + // Best-effort cleanup: ignore disposal errors during fallback path. + } +} + +function disposeTensorMap(tensors: Record): void { + for (const tensor of Object.values(tensors)) { + disposeTensor(tensor); + } +} + +function computeAdaptiveDecodeStepLimit(maxDecodeSteps: number, textHint?: string): number { + const normalized = (textHint ?? '').trim(); + if (!normalized) return Math.min(maxDecodeSteps, 96); + + const chars = normalized.length; + const words = normalized.split(/\s+/).filter(Boolean).length; + const estTokens = Math.max(words * 3, Math.ceil(chars / 2)); + const adaptive = Math.max(64, Math.min(maxDecodeSteps, estTokens + 24)); + return adaptive; +} + +function assertWithinDeadline(deadlineMs: number): void { + if (Date.now() > deadlineMs) { + throw new Error(`Whisper alignment timed out after ${ALIGNMENT_TIMEOUT_MS}ms`); + } +} + +function makeInFlightCoalesceKey(audioBuffer: TTSAudioBuffer, text: string, lang?: string): string { + const bytes = new Uint8Array(audioBuffer); + const span = 4096; + const head = bytes.subarray(0, Math.min(span, bytes.length)); + const tailStart = Math.max(0, bytes.length - span); + const tail = bytes.subarray(tailStart); + return createHash('sha256') + .update(text) + .update('\0') + .update(lang ?? '') + .update('\0') + .update(String(bytes.length)) + .update('\0') + .update(head) + .update('\0') + .update(tail) + .digest('hex'); +} + +function buildEmptyPastFeeds() { + if (state.emptyPastFeedsTemplate) return state.emptyPastFeedsTemplate; + + const feeds: Record = {}; + const emptyDecoderPast = new Float32Array(0); + const emptyEncoderPast = new Float32Array(1 * WHISPER_NUM_HEADS * 1500 * WHISPER_HEAD_DIM); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + feeds[`past_key_values.${i}.decoder.key`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.decoder.value`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + + // First pass still expects encoder KV inputs in the merged decoder graph. + feeds[`past_key_values.${i}.encoder.key`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.encoder.value`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + } + + state.emptyPastFeedsTemplate = feeds; + return state.emptyPastFeedsTemplate; +} + +function argmax(values: Float32Array): number | null { + let bestIdx = 0; + let bestScore = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < values.length; i += 1) { + const score = values[i]; + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + return Number.isFinite(bestScore) ? bestIdx : null; +} + +function applyTokenSuppression(logits: Float32Array, tokens: Set) { + for (const tokenId of tokens) { + if (tokenId >= 0 && tokenId < logits.length) { + logits[tokenId] = Number.NEGATIVE_INFINITY; + } + } +} + +function logSoftmax(input: Float32Array): Float32Array { + let max = Number.NEGATIVE_INFINITY; + for (let i = 0; i < input.length; i += 1) { + if (input[i] > max) max = input[i]; + } + if (!Number.isFinite(max)) { + return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY); + } + + let sum = 0; + for (let i = 0; i < input.length; i += 1) { + sum += Math.exp(input[i] - max); + } + const logSum = Math.log(sum); + + const out = new Float32Array(input.length); + for (let i = 0; i < input.length; i += 1) { + out[i] = input[i] - max - logSum; + } + return out; +} + +function applyWhisperTimestampLogitsRules(input: { + logits: Float32Array; + generated: number[]; + beginIndex: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; +}) { + const { + logits, + generated, + beginIndex, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + } = input; + + if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) { + logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY; + } + + if (generated.length === beginIndex) { + const upper = Math.min(timestampBeginTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const seq = generated.slice(beginIndex); + const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId; + const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId; + + if (lastWasTimestamp) { + if (penultimateWasTimestamp) { + for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } else { + const upper = Math.min(eosTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + } + + if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) { + const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex; + for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const textUpper = Math.min(timestampBeginTokenId, logits.length); + if (textUpper <= 0 || textUpper >= logits.length) return; + + const logprobs = logSoftmax(logits); + + let maxTextTokenLogprob = Number.NEGATIVE_INFINITY; + for (let i = 0; i < textUpper; i += 1) { + if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i]; + } + + let timestampProbMass = 0; + for (let i = textUpper; i < logprobs.length; i += 1) { + timestampProbMass += Math.exp(logprobs[i]); + } + const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY; + + if (timestampLogprob > maxTextTokenLogprob) { + for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } +} + +async function getRuntime(): Promise { + if (state.runtimePromise) return state.runtimePromise; + + state.runtimePromise = (async () => { + await ensureWhisperModel(); + await loadOfficialMelFilters(); + + const [configRaw, generationRaw, tokenizerJsonRaw, tokenizerConfigRaw] = await Promise.all([ + readFile(WHISPER_CONFIG_PATH, 'utf8'), + readFile(WHISPER_GENERATION_CONFIG_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_CONFIG_PATH, 'utf8'), + ]); + + const config = JSON.parse(configRaw) as { + decoder_start_token_id?: number; + eos_token_id?: number; + forced_decoder_ids?: Array<[number, number | null]>; + }; + + const generationConfig = JSON.parse(generationRaw) as { + no_timestamps_token_id?: number; + max_initial_timestamp_index?: number; + suppress_tokens?: number[]; + begin_suppress_tokens?: number[]; + max_length?: number; + alignment_heads?: Array<[number, number]>; + }; + + const tokenizer = new Tokenizer(JSON.parse(tokenizerJsonRaw), JSON.parse(tokenizerConfigRaw)); + + const promptStartToken = Number(config.decoder_start_token_id ?? 50258); + const eosTokenId = Number(config.eos_token_id ?? 50257); + const noTimestampsTokenId = Number(generationConfig.no_timestamps_token_id ?? 50363); + const timestampBeginTokenId = noTimestampsTokenId + 1; + const maxInitialTimestampIndex = Number(generationConfig.max_initial_timestamp_index ?? 50); + const configuredMaxDecodeSteps = Number(generationConfig.max_length ?? 448); + const maxDecodeSteps = Math.min(configuredMaxDecodeSteps, MAX_DECODE_STEPS_CAP); + const alignmentHeads = Array.isArray(generationConfig.alignment_heads) + ? generationConfig.alignment_heads + .filter((head): head is [number, number] => Array.isArray(head) && head.length === 2) + .map(([layer, head]) => [Number(layer), Number(head)] as [number, number]) + : []; + + const forcedDecoder = Array.isArray(config.forced_decoder_ids) ? config.forced_decoder_ids : []; + const defaultLanguageFromForced = forcedDecoder.find(([index, id]) => index === 1 && typeof id === 'number')?.[1] ?? null; + const transcribeFromForced = forcedDecoder.find(([index, id]) => index === 2 && typeof id === 'number')?.[1] ?? null; + + const defaultLanguageToken = Number(defaultLanguageFromForced ?? tokenizer.token_to_id('<|en|>') ?? 50259); + const transcribeToken = Number(transcribeFromForced ?? tokenizer.token_to_id('<|transcribe|>') ?? 50359); + + const stableSessionOptions: ort.InferenceSession.SessionOptions = { + executionProviders: ['cpu'], + graphOptimizationLevel: 'disabled', + intraOpNumThreads: 1, + interOpNumThreads: 1, + executionMode: 'sequential', + enableCpuMemArena: false, + enableMemPattern: false, + }; + + const encoder = await ort.InferenceSession.create(WHISPER_ENCODER_MODEL_PATH, stableSessionOptions); + const decoderMerged = await ort.InferenceSession.create(WHISPER_DECODER_MERGED_MODEL_PATH, stableSessionOptions); + const decoderWithPast = await ort.InferenceSession.create(WHISPER_DECODER_WITH_PAST_MODEL_PATH, stableSessionOptions); + + const alignmentLayers = [...new Set(alignmentHeads.map(([layer]) => layer))]; + const prefillFetches: string[] = ['logits']; + const stepFetches: string[] = ['logits']; + const mergedOutputNames = new Set(decoderMerged.outputNames); + const withPastOutputNames = new Set(decoderWithPast.outputNames); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + const decoderKey = `present.${i}.decoder.key`; + const decoderValue = `present.${i}.decoder.value`; + if (mergedOutputNames.has(decoderKey)) prefillFetches.push(decoderKey); + if (mergedOutputNames.has(decoderValue)) prefillFetches.push(decoderValue); + if (withPastOutputNames.has(decoderKey)) stepFetches.push(decoderKey); + if (withPastOutputNames.has(decoderValue)) stepFetches.push(decoderValue); + + const encoderKey = `present.${i}.encoder.key`; + const encoderValue = `present.${i}.encoder.value`; + if (mergedOutputNames.has(encoderKey)) prefillFetches.push(encoderKey); + if (mergedOutputNames.has(encoderValue)) prefillFetches.push(encoderValue); + } + + for (const layer of alignmentLayers) { + const key = `cross_attentions.${layer}`; + if (mergedOutputNames.has(key)) prefillFetches.push(key); + if (withPastOutputNames.has(key)) stepFetches.push(key); + } + + return { + encoder, + decoderMerged, + decoderWithPast, + tokenizer, + promptStartToken, + defaultLanguageToken, + transcribeToken, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + maxDecodeSteps, + suppressTokens: new Set((generationConfig.suppress_tokens ?? []).map((v) => Number(v))), + beginSuppressTokens: new Set((generationConfig.begin_suppress_tokens ?? []).map((v) => Number(v))), + alignmentHeads, + prefillFetches, + stepFetches, + }; + })().catch((error) => { + state.runtimePromise = null; + throw error; + }); + + return state.runtimePromise; +} + +function resolveLanguageToken(runtime: WhisperRuntime, lang?: string): number { + const parsed = parseLanguageCode(lang); + if (!parsed) return runtime.defaultLanguageToken; + + const candidate = runtime.tokenizer.token_to_id(`<|${parsed}|>`); + return typeof candidate === 'number' ? candidate : runtime.defaultLanguageToken; +} + +async function runWhisperOnnx( + audioSamples: Float32Array, + opts: WhisperAlignmentOptions, + numFrames: number, + deadlineMs: number, +): Promise { + assertWithinDeadline(deadlineMs); + const runtime = await getRuntime(); + const decodeStepLimit = computeAdaptiveDecodeStepLimit(runtime.maxDecodeSteps, opts.textHint); + const mel = computeLogMelSpectrogram(audioSamples); + const encoderPast: Record = {}; + const decoderPast: Record = {}; + const crossAttentions: Record = {}; + let encoderHidden: ort.Tensor | null = null; + let outputs: Record | null = null; + + try { + const encoderOutputs = await runtime.encoder.run({ + input_features: mel, + }, ['last_hidden_state']); + encoderHidden = encoderOutputs.last_hidden_state; + + const languageToken = resolveLanguageToken(runtime, opts.lang); + const promptTokens = [ + runtime.promptStartToken, + languageToken, + runtime.transcribeToken, + ]; + + const generated: number[] = [...promptTokens]; + const emptyPastFeeds = buildEmptyPastFeeds(); + type LayerChunk = { + data: Float32Array; + heads: number; + seqLen: number; + frames: number; + }; + const selectedHeadsByLayer = new Map(); + for (const [layer, head] of runtime.alignmentHeads) { + const existing = selectedHeadsByLayer.get(layer) ?? []; + if (!existing.includes(head)) existing.push(head); + selectedHeadsByLayer.set(layer, existing); + } + for (const [layer, heads] of selectedHeadsByLayer) { + heads.sort((a, b) => a - b); + selectedHeadsByLayer.set(layer, heads); + } + const crossAttentionChunks = new Map(); + + const captureCrossAttentions = (stepOutputs: Record, prefill = false) => { + for (const [layer, selectedHeads] of selectedHeadsByLayer) { + const key = `cross_attentions.${layer}`; + const tensor = stepOutputs[key]; + if (!tensor) continue; + const [, , seqLen, frames] = tensor.dims; + const data = tensor.data as Float32Array; + const rowsToKeep = prefill ? seqLen : 1; + const seqStart = prefill ? 0 : Math.max(0, seqLen - 1); + const copied = new Float32Array(selectedHeads.length * rowsToKeep * frames); + for (let h = 0; h < selectedHeads.length; h += 1) { + const sourceHead = selectedHeads[h]!; + for (let s = 0; s < rowsToKeep; s += 1) { + const sourceSeq = seqStart + s; + for (let f = 0; f < frames; f += 1) { + const src = (((sourceHead * seqLen) + sourceSeq) * frames) + f; + const dst = (((h * rowsToKeep) + s) * frames) + f; + copied[dst] = data[src] ?? 0; + } + } + } + const list = crossAttentionChunks.get(layer) ?? []; + list.push({ data: copied, heads: selectedHeads.length, seqLen: rowsToKeep, frames }); + crossAttentionChunks.set(layer, list); + } + }; + const beginIndex = promptTokens.length; + + // Prefill: run prompt in merged decoder (non-cache branch), identical to first + // forward pass in transformers.js/transformers generation. + const prefillInputIds = tensorFromInt64(generated); + const prefillUseCacheBranch = new ort.Tensor('bool', Uint8Array.from([0]), [1]); + const prefillFeeds: Record = { + input_ids: prefillInputIds, + encoder_hidden_states: encoderHidden, + use_cache_branch: prefillUseCacheBranch, + ...emptyPastFeeds, + }; + try { + assertWithinDeadline(deadlineMs); + outputs = await runtime.decoderMerged.run(prefillFeeds, runtime.prefillFetches); + } finally { + disposeTensor(prefillInputIds); + disposeTensor(prefillUseCacheBranch); + } + captureCrossAttentions(outputs, true); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + encoderPast[`past_key_values.${i}.encoder.key`] = outputs[`present.${i}.encoder.key`]; + encoderPast[`past_key_values.${i}.encoder.value`] = outputs[`present.${i}.encoder.value`]; + decoderPast[`past_key_values.${i}.decoder.key`] = outputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = outputs[`present.${i}.decoder.value`]; + } + + for (let step = 0; step < decodeStepLimit; step += 1) { + assertWithinDeadline(deadlineMs); + if (!outputs) break; + const logits = outputs.logits; + const logitsData = logits.data as Float32Array; + const vocabSize = logits.dims[2] ?? 0; + const offset = logitsData.length - vocabSize; + const lastLogits = logitsData.subarray(offset); + + applyTokenSuppression(lastLogits, runtime.suppressTokens); + if (generated.length === beginIndex) { + applyTokenSuppression(lastLogits, runtime.beginSuppressTokens); + } + applyWhisperTimestampLogitsRules({ + logits: lastLogits, + generated, + beginIndex, + eosTokenId: runtime.eosTokenId, + noTimestampsTokenId: runtime.noTimestampsTokenId, + timestampBeginTokenId: runtime.timestampBeginTokenId, + maxInitialTimestampIndex: runtime.maxInitialTimestampIndex, + }); + + const nextToken = argmax(lastLogits) ?? runtime.eosTokenId; + generated.push(nextToken); + if (nextToken === runtime.eosTokenId) break; + + const previousDecoderPast = { ...decoderPast }; + const stepInputIds = tensorFromInt64([nextToken]); + const stepFeeds: Record = { + input_ids: stepInputIds, + ...previousDecoderPast, + ...encoderPast, + }; + let nextOutputs: Record; + try { + assertWithinDeadline(deadlineMs); + nextOutputs = await runtime.decoderWithPast.run(stepFeeds, runtime.stepFetches); + } finally { + disposeTensor(stepInputIds); + } + captureCrossAttentions(nextOutputs, false); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + decoderPast[`past_key_values.${i}.decoder.key`] = nextOutputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = nextOutputs[`present.${i}.decoder.value`]; + } + + disposeTensorMap(previousDecoderPast); + disposeTensor(outputs.logits); + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + outputs = nextOutputs; + } + + if (crossAttentionChunks.size === 0) { + return []; + } + + const remappedAlignmentHeads: Array<[number, number]> = runtime.alignmentHeads + .map(([layer, head]) => { + const selectedHeads = selectedHeadsByLayer.get(layer) ?? []; + const remappedHead = selectedHeads.indexOf(head); + if (remappedHead < 0) return null; + return [layer, remappedHead] as [number, number]; + }) + .filter((pair): pair is [number, number] => pair !== null); + + for (let layer = 0; layer < WHISPER_NUM_LAYERS; layer += 1) { + const chunks = crossAttentionChunks.get(layer); + if (!chunks || !chunks.length) continue; + + const heads = chunks[0].heads; + const frames = chunks[0].frames; + const concatSeqLen = chunks.reduce((sum, chunk) => sum + chunk.seqLen, 0); + const merged = new Float32Array(1 * heads * concatSeqLen * frames); + let seqOffset = 0; + + for (const chunk of chunks) { + const { data, seqLen, frames: tensorFrames } = chunk; + const copyFrames = Math.min(frames, tensorFrames); + + for (let h = 0; h < heads; h += 1) { + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < copyFrames; f += 1) { + const src = (((h * seqLen) + s) * tensorFrames) + f; + const dst = (((h * concatSeqLen) + (seqOffset + s)) * frames) + f; + merged[dst] = data[src] ?? 0; + } + } + } + seqOffset += seqLen; + } + + crossAttentions[`cross_attentions.${layer}`] = new ort.Tensor('float32', merged, [1, heads, concatSeqLen, frames]); + } + + const tokenStartTimestamps = extractTokenStartTimestamps({ + crossAttentions, + decoderLayers: WHISPER_NUM_LAYERS, + alignmentHeads: remappedAlignmentHeads, + numFrames, + numInputIds: promptTokens.length, + timePrecision: 0.02, + sequenceLength: generated.length, + }); + + const timedWords = buildWordsFromTimestampedTokens({ + tokens: generated, + tokenStartTimestamps, + tokenizer: runtime.tokenizer, + eosTokenId: runtime.eosTokenId, + promptLength: promptTokens.length, + timestampBeginTokenId: runtime.timestampBeginTokenId, + timePrecision: 0.02, + language: parseLanguageCode(opts.lang) ?? 'english', + }); + + const maxSec = Math.max(0, numFrames * 0.02); + return timedWords.map((word) => ({ + word: word.word, + start: Math.min(maxSec, Math.max(0, word.startSec)), + end: Math.min(maxSec, Math.max(0, word.endSec)), + })); + } finally { + disposeTensor(mel); + if (outputs?.logits) disposeTensor(outputs.logits); + if (outputs) { + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + } + disposeTensorMap(crossAttentions); + disposeTensorMap(decoderPast); + disposeTensorMap(encoderPast); + disposeTensor(encoderHidden); + } +} + +export async function alignAudioWithText( + audioBuffer: TTSAudioBuffer, + text: string, + cacheKey?: string, + opts: WhisperAlignmentOptions = {}, +): Promise { + if (!text.trim()) return []; + + if (cacheKey && alignmentCache.has(cacheKey)) { + const cached = alignmentCache.get(cacheKey)!; + alignmentCache.delete(cacheKey); + alignmentCache.set(cacheKey, cached); + return cached; + } + + if (cacheKey) { + const inFlight = alignmentInFlight.get(cacheKey); + if (inFlight) return inFlight; + } + const inFlightKey = cacheKey ?? makeInFlightCoalesceKey(audioBuffer, text, opts.lang); + const shared = alignmentInFlight.get(inFlightKey); + if (shared) return shared; + + state.pendingAlignments += 1; + const run = (async (): Promise => { + const deadlineMs = Date.now() + ALIGNMENT_TIMEOUT_MS; + const previous = state.alignMutex; + let release!: () => void; + state.alignMutex = new Promise((resolve) => { + release = resolve; + }); + + await previous; + + // Another request with the same cache key may have completed while this one + // was waiting on the mutex. + if (cacheKey && alignmentCache.has(cacheKey)) { + const cached = alignmentCache.get(cacheKey)!; + alignmentCache.delete(cacheKey); + alignmentCache.set(cacheKey, cached); + release(); + return cached; + } + + let tmpBase = ''; + let inputPath = ''; + let pcmPath = ''; + + try { + tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-')); + inputPath = join(tmpBase, `${randomUUID()}-input.bin`); + pcmPath = join(tmpBase, `${randomUUID()}-input.pcm16`); + + await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); + await decodeToPcm16(inputPath, pcmPath); + + const pcmBytes = await readFile(pcmPath); + const decodedSamples = pcm16ToFloat32(pcmBytes); + const effectiveSampleLength = Math.min(decodedSamples.length, N_SAMPLES); + const effectiveFrameCount = Math.max(1, Math.floor((effectiveSampleLength / HOP_LENGTH) / 2)); + const normalizedAudio = padOrTrimAudio(decodedSamples); + + const words = await runWhisperOnnx( + normalizedAudio, + { ...opts, textHint: text }, + effectiveFrameCount, + deadlineMs, + ); + const alignment = mapWordsToSentenceOffsets(text, words); + const result: TTSSentenceAlignment[] = [alignment]; + + if (cacheKey) { + if (alignmentCache.has(cacheKey)) { + alignmentCache.delete(cacheKey); + } + alignmentCache.set(cacheKey, result); + while (alignmentCache.size > ALIGNMENT_CACHE_MAX_ENTRIES) { + const oldest = alignmentCache.keys().next().value; + if (!oldest) break; + alignmentCache.delete(oldest); + } + } + + return result; + } finally { + if (tmpBase) { + await rm(tmpBase, { recursive: true, force: true }).catch(() => {}); + } + release(); + state.pendingAlignments = Math.max(0, state.pendingAlignments - 1); + } + })(); + + alignmentInFlight.set(inFlightKey, run); + run.finally(() => { + if (alignmentInFlight.get(inFlightKey) === run) { + alignmentInFlight.delete(inFlightKey); + } + }); + return run; +} + +export function makeWhisperCacheKey(input: WhisperRequestBody): string { + return createHash('sha256') + .update( + JSON.stringify({ + text: input.text, + lang: input.lang || '', + audioLen: input.audio?.length || 0, + }), + ) + .digest('hex'); +} diff --git a/compute/core/src/whisper/ensureModel.ts b/compute/core/src/whisper/ensureModel.ts new file mode 100644 index 0000000..4d2a05c --- /dev/null +++ b/compute/core/src/whisper/ensureModel.ts @@ -0,0 +1,242 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; +import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '../runtime/docstore'; +import manifest from './model/manifest.json'; + +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped'); +const STATIC_LICENSE_PATH = path.join(MODULE_DIR, 'model', 'LICENSE.txt'); + +export const WHISPER_CONFIG_PATH = path.join(MODEL_DIR, 'config.json'); +export const WHISPER_GENERATION_CONFIG_PATH = path.join(MODEL_DIR, 'generation_config.json'); +export const WHISPER_TOKENIZER_PATH = path.join(MODEL_DIR, 'tokenizer.json'); +export const WHISPER_TOKENIZER_CONFIG_PATH = path.join(MODEL_DIR, 'tokenizer_config.json'); +export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_int8.onnx'); +export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_int8.onnx'); +export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_int8.onnx'); + +const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main'; +const WHISPER_MODEL_BASE_URL_ENV = 'WHISPER_MODEL_BASE_URL'; + +const MODEL_RELATIVE_PATHS: string[] = [ + 'config.json', + 'generation_config.json', + 'tokenizer.json', + 'tokenizer_config.json', + 'merges.txt', + 'vocab.json', + 'normalizer.json', + 'added_tokens.json', + 'preprocessor_config.json', + 'special_tokens_map.json', + 'onnx/encoder_model_int8.onnx', + 'onnx/decoder_model_merged_int8.onnx', + 'onnx/decoder_with_past_model_int8.onnx', +]; + +const DEFAULT_URLS: Record = { + 'config.json': `${BASE_MODEL_URL}/config.json`, + 'generation_config.json': `${BASE_MODEL_URL}/generation_config.json`, + 'tokenizer.json': `${BASE_MODEL_URL}/tokenizer.json`, + 'tokenizer_config.json': `${BASE_MODEL_URL}/tokenizer_config.json`, + 'merges.txt': `${BASE_MODEL_URL}/merges.txt`, + 'vocab.json': `${BASE_MODEL_URL}/vocab.json`, + 'normalizer.json': `${BASE_MODEL_URL}/normalizer.json`, + 'added_tokens.json': `${BASE_MODEL_URL}/added_tokens.json`, + 'preprocessor_config.json': `${BASE_MODEL_URL}/preprocessor_config.json`, + 'special_tokens_map.json': `${BASE_MODEL_URL}/special_tokens_map.json`, + 'onnx/encoder_model_int8.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_int8.onnx`, + 'onnx/decoder_model_merged_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_int8.onnx`, + 'onnx/decoder_with_past_model_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_int8.onnx`, +}; + +type ManifestEntry = { path: string; sha256?: string; size?: number }; + +export interface WhisperArtifactSpec { + path: string; + sha256?: string; + size?: number; + url: string; +} + +export interface WhisperStaticArtifactSpec { + path: string; + sha256?: string; + size?: number; + sourcePath: string; +} + +export type WhisperFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +const MANIFEST_FILES = manifest.files as ManifestEntry[]; +const MODEL_FILES = MANIFEST_FILES.filter((entry) => entry.path !== 'LICENSE.txt'); +const LICENSE_FILE = MANIFEST_FILES.find((entry) => entry.path === 'LICENSE.txt'); + +function normalizeExpected(entry: { sha256?: string; size?: number }): { sha256: string | null; size: number } { + return { + sha256: typeof entry.sha256 === 'string' ? entry.sha256.toLowerCase() : null, + size: Number(entry.size ?? 0), + }; +} + +function resolvePath(relativePath: string, modelDir: string): string { + return path.join(modelDir, relativePath); +} + +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + +function resolveUrl(relativePath: string): string { + const overrideBase = process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim(); + if (overrideBase) { + return joinModelUrl(overrideBase, relativePath); + } + const fallback = DEFAULT_URLS[relativePath]; + if (!fallback) { + throw new Error(`No default URL configured for Whisper model artifact: ${relativePath}`); + } + return fallback; +} + +function sha256OfBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function verifyBytes(bytes: Uint8Array, expected: { sha256?: string; size?: number }): boolean { + const normalized = normalizeExpected(expected); + if (Number.isFinite(normalized.size) && normalized.size > 0 && bytes.byteLength !== normalized.size) { + return false; + } + if (!normalized.sha256) return true; + return sha256OfBytes(bytes) === normalized.sha256; +} + +async function verifyFile(filePath: string, expected: { sha256?: string; size?: number }): Promise { + const bytes = await readFile(filePath); + return verifyBytes(bytes, expected); +} + +async function downloadToFile(fetchImpl: WhisperFetch, url: string, outPath: string): Promise { + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +export async function ensureWhisperArtifacts(options: { + modelDir: string; + artifacts: WhisperArtifactSpec[]; + staticArtifacts?: WhisperStaticArtifactSpec[]; + fetchImpl?: WhisperFetch; +}): Promise { + const { + modelDir, + artifacts, + staticArtifacts = [], + fetchImpl = fetch, + } = options; + + try { + await Promise.all(artifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper artifact: ${artifact.path}`); + } + })); + + await Promise.all(staticArtifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper static artifact: ${artifact.path}`); + } + })); + + return; + } catch { + // Continue to repair/download. + } + + for (const artifact of artifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + const tmp = `${target}.tmp`; + + await mkdir(targetDir, { recursive: true }); + await downloadToFile(fetchImpl, artifact.url, tmp); + if (!(await verifyFile(tmp, artifact))) { + await unlink(tmp).catch(() => undefined); + throw new Error(`Whisper artifact checksum verification failed: ${artifact.path}`); + } + await rename(tmp, target); + } + + for (const artifact of staticArtifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + await mkdir(targetDir, { recursive: true }); + await copyFile(artifact.sourcePath, target); + if (!(await verifyFile(target, artifact))) { + throw new Error(`Whisper static artifact checksum verification failed: ${artifact.path}`); + } + } +} + +export function createSingleflightRunner(work: () => Promise): () => Promise { + let inflight: Promise | null = null; + return async () => { + if (inflight) return inflight; + inflight = work().finally(() => { + inflight = null; + }); + return inflight; + }; +} + +async function ensureModelInternal(): Promise { + if (process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim()) { + for (const relativePath of MODEL_RELATIVE_PATHS) { + if (!(relativePath in DEFAULT_URLS)) { + throw new Error(`Missing default URL path mapping for Whisper artifact: ${relativePath}`); + } + } + } + + const artifacts: WhisperArtifactSpec[] = MODEL_FILES.map((entry) => ({ + path: entry.path, + sha256: entry.sha256, + size: entry.size, + url: resolveUrl(entry.path), + })); + + const staticArtifacts: WhisperStaticArtifactSpec[] = LICENSE_FILE + ? [{ + path: LICENSE_FILE.path, + sha256: LICENSE_FILE.sha256, + size: LICENSE_FILE.size, + sourcePath: STATIC_LICENSE_PATH, + }] + : []; + + await ensureWhisperArtifacts({ + modelDir: MODEL_DIR, + artifacts, + staticArtifacts, + }); + + return WHISPER_ENCODER_MODEL_PATH; +} + +const ensureWhisperModelSingleflight = createSingleflightRunner(ensureModelInternal); + +export async function ensureWhisperModel(): Promise { + return ensureWhisperModelSingleflight(); +} diff --git a/compute/core/src/whisper/model/LICENSE.txt b/compute/core/src/whisper/model/LICENSE.txt new file mode 100644 index 0000000..d255525 --- /dev/null +++ b/compute/core/src/whisper/model/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/compute/core/src/whisper/model/manifest.json b/compute/core/src/whisper/model/manifest.json new file mode 100644 index 0000000..2fffd3d --- /dev/null +++ b/compute/core/src/whisper/model/manifest.json @@ -0,0 +1,76 @@ +{ + "name": "whisper-base_timestamped-int8", + "version": "onnx-community/whisper-base_timestamped@608c49e61301901684bc36cac8f74b95ff6b5a8e", + "files": [ + { + "path": "config.json", + "sha256": "f4d0608f7d918166da7edb3e188de5ef1bfe70d9802e785d271fd88111e9cf4b", + "size": 2243 + }, + { + "path": "generation_config.json", + "sha256": "61070cf8de25b1e9256e8e102ded49d8d24a8369ed36ef84fdf21549e68125a0", + "size": 3832 + }, + { + "path": "tokenizer.json", + "sha256": "27fc476bfe7f17299480be2273fc0608e4d5a99aba2ab5dec5374b4482d1a566", + "size": 2480466 + }, + { + "path": "tokenizer_config.json", + "sha256": "2e036e4dbacfdeb7242c7d4ec4149f4a16e86026048f94d1637e3a8ee9c6a573", + "size": 282682 + }, + { + "path": "merges.txt", + "sha256": "2df2990a395e35e8dfbc7511e08c12d56018d8d04691e0133e5d63b21e154dc6", + "size": 493869 + }, + { + "path": "vocab.json", + "sha256": "50d6a919f0a0601d56a04eb583c780d18553aa388254ba3158eb6a00f13e2c1a", + "size": 1036584 + }, + { + "path": "normalizer.json", + "sha256": "bf1c507dc8724ca9cf9903640dacfb69dae2f00edee4f21ceba106a7392f26dd", + "size": 52666 + }, + { + "path": "added_tokens.json", + "sha256": "9715fd2243b6f06a5858b5e32950d2853f73dd5bc201aafcf76f5082a2d8acd1", + "size": 34604 + }, + { + "path": "preprocessor_config.json", + "sha256": "a6a76d28c93edb273669eb9e0b0636a2bddbb1272c3261e47b7ca6dfdbac1b8d", + "size": 339 + }, + { + "path": "special_tokens_map.json", + "sha256": "e67ae3a0aaa99abcd9f187138e12db1f65c16a14761c50ef10eef2c174a7a691", + "size": 2194 + }, + { + "path": "onnx/encoder_model_int8.onnx", + "sha256": "152da96dd8ff3f28f3fadabc2e8960405a277846453ff94ed411fe935a72917f", + "size": 23159150 + }, + { + "path": "onnx/decoder_model_merged_int8.onnx", + "sha256": "cf9a8d5bcddc0917a0078135b484cedcaf44f28909cd91910abd29dced9171db", + "size": 53712708 + }, + { + "path": "onnx/decoder_with_past_model_int8.onnx", + "sha256": "bdd92860d0ed7dff2aca623963378cbba1b617bfae127356db1c8aa8baa930ef", + "size": 50131672 + }, + { + "path": "LICENSE.txt", + "sha256": "b5d65a59060e68c4ff940e1eddfa6f94b2d68fdf58ed7f4dd57721c997e35e9d", + "size": 1063 + } + ] +} diff --git a/compute/core/src/whisper/model/mel_filters.npz b/compute/core/src/whisper/model/mel_filters.npz new file mode 100644 index 0000000000000000000000000000000000000000..28ea26909dbdfd608aef67afc4d74d7961ae4bb6 GIT binary patch literal 4271 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 AQvd(} literal 0 HcmV?d00001 diff --git a/compute/core/src/whisper/spectral.ts b/compute/core/src/whisper/spectral.ts new file mode 100644 index 0000000..b7223cc --- /dev/null +++ b/compute/core/src/whisper/spectral.ts @@ -0,0 +1,21 @@ +export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array { + const coeffs = new Float64Array(freqBins); + for (let k = 0; k < freqBins; k += 1) { + coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize); + } + return coeffs; +} + +export function goertzelPower(samples: Float32Array, coeff: number): number { + let s1 = 0; + let s2 = 0; + for (let i = 0; i < samples.length; i += 1) { + const s0 = samples[i] + (coeff * s1) - s2; + s2 = s1; + s1 = s0; + } + + const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2); + if (!Number.isFinite(power) || power < 0) return 0; + return power; +} diff --git a/compute/core/src/whisper/token-timestamps.ts b/compute/core/src/whisper/token-timestamps.ts new file mode 100644 index 0000000..47edc1d --- /dev/null +++ b/compute/core/src/whisper/token-timestamps.ts @@ -0,0 +1,449 @@ +import type { Tokenizer } from '@huggingface/tokenizers'; +import type * as ort from 'onnxruntime-node'; + +const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E'; +const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu'); + +type TokenTimestamp = [start: number, end: number]; + +export interface WhisperWordTiming { + word: string; + startSec: number; + endSec: number; +} + +function medianFilter(data: Float32Array, windowSize: number): Float32Array { + if (windowSize % 2 === 0 || windowSize <= 0) { + throw new Error('Window size must be a positive odd number'); + } + + const output = new Float32Array(data.length); + const buffer = new Float32Array(windowSize); + const halfWindow = Math.floor(windowSize / 2); + + for (let i = 0; i < data.length; i += 1) { + let valuesIndex = 0; + for (let j = -halfWindow; j <= halfWindow; j += 1) { + let index = i + j; + if (index < 0) { + index = Math.abs(index); + } else if (index >= data.length) { + index = (2 * (data.length - 1)) - index; + } + buffer[valuesIndex] = data[index]; + valuesIndex += 1; + } + + const sortable = Array.from(buffer); + sortable.sort((a, b) => a - b); + output[i] = sortable[halfWindow] ?? 0; + } + + return output; +} + +function dynamicTimeWarping(matrix: Float32Array[], rows: number, cols: number): [number[], number[]] { + const cost: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(Number.POSITIVE_INFINITY)); + const trace: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(-1)); + cost[0][0] = 0; + + for (let j = 1; j <= cols; j += 1) { + for (let i = 1; i <= rows; i += 1) { + const c0 = cost[i - 1][j - 1]; + const c1 = cost[i - 1][j]; + const c2 = cost[i][j - 1]; + let c: number; + let t: number; + if (c0 < c1 && c0 < c2) { + c = c0; + t = 0; + } else if (c1 < c0 && c1 < c2) { + c = c1; + t = 1; + } else { + c = c2; + t = 2; + } + cost[i][j] = matrix[i - 1][j - 1] + c; + trace[i][j] = t; + } + } + + for (let i = 0; i <= cols; i += 1) trace[0][i] = 2; + for (let i = 0; i <= rows; i += 1) trace[i][0] = 1; + + let i = rows; + let j = cols; + const textIndices: number[] = []; + const timeIndices: number[] = []; + while (i > 0 || j > 0) { + textIndices.push(i - 1); + timeIndices.push(j - 1); + const step = trace[i][j]; + if (step === 0) { + i -= 1; + j -= 1; + } else if (step === 1) { + i -= 1; + } else if (step === 2) { + j -= 1; + } else { + throw new Error(`Unexpected DTW trace state at [${i}, ${j}]`); + } + } + + textIndices.reverse(); + timeIndices.reverse(); + return [textIndices, timeIndices]; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function decodeTokens(tokenizer: Pick, tokens: number[]): string { + return tokenizer.decode(tokens, { skip_special_tokens: false }); +} + +function splitTokensOnUnicode( + tokenizer: Pick, + tokens: number[], +): [string[], number[][], number[][]] { + const decodedFull = decodeTokens(tokenizer, tokens); + const replacementChar = '\uFFFD'; + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + let currentTokens: number[] = []; + let currentIndices: number[] = []; + let unicodeOffset = 0; + + for (let i = 0; i < tokens.length; i += 1) { + currentTokens.push(tokens[i]); + currentIndices.push(i); + + const decoded = decodeTokens(tokenizer, currentTokens); + if ( + !decoded.includes(replacementChar) + || decodedFull[unicodeOffset + decoded.indexOf(replacementChar)] === replacementChar + ) { + words.push(decoded); + wordTokens.push(currentTokens); + tokenIndices.push(currentIndices); + currentTokens = []; + currentIndices = []; + unicodeOffset += decoded.length; + } + } + + return [words, wordTokens, tokenIndices]; +} + +function splitTokensOnSpaces( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, +): [string[], number[][], number[][]] { + const [subwords, subwordTokens, subwordIndices] = splitTokensOnUnicode(tokenizer, tokens); + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + + for (let i = 0; i < subwords.length; i += 1) { + const subword = subwords[i]; + const tokenList = subwordTokens[i]; + const indices = subwordIndices[i]; + const special = tokenList[0] >= eosTokenId; + const withSpace = subword.startsWith(' '); + const trimmed = subword.trim(); + const punctuation = PUNCTUATION_ONLY_REGEX.test(trimmed); + + if (special || withSpace || punctuation || words.length === 0) { + words.push(subword); + wordTokens.push([...tokenList]); + tokenIndices.push([...indices]); + } else { + const ix = words.length - 1; + words[ix] += subword; + wordTokens[ix].push(...tokenList); + tokenIndices[ix].push(...indices); + } + } + + return [words, wordTokens, tokenIndices]; +} + +function mergePunctuations( + words: string[], + tokens: number[][], + indices: number[][], + prependPunctuations = '"\'“¡¿([{-', + appendPunctuations = '"\'.。,,!!??::”)]}、', +): [string[], number[][], number[][]] { + const newWords = words.map((w) => `${w}`); + const newTokens = tokens.map((t) => [...t]); + const newIndices = indices.map((idx) => [...idx]); + + let i = newWords.length - 2; + let j = newWords.length - 1; + while (i >= 0) { + if (newWords[i].startsWith(' ') && prependPunctuations.includes(newWords[i].trim())) { + newWords[j] = newWords[i] + newWords[j]; + newTokens[j] = [...newTokens[i], ...newTokens[j]]; + newIndices[j] = [...newIndices[i], ...newIndices[j]]; + newWords[i] = ''; + newTokens[i] = []; + newIndices[i] = []; + } else { + j = i; + } + i -= 1; + } + + i = 0; + j = 1; + while (j < newWords.length) { + if (!newWords[i].endsWith(' ') && appendPunctuations.includes(newWords[j])) { + newWords[i] += newWords[j]; + newTokens[i] = [...newTokens[i], ...newTokens[j]]; + newIndices[i] = [...newIndices[i], ...newIndices[j]]; + newWords[j] = ''; + newTokens[j] = []; + newIndices[j] = []; + } else { + i = j; + } + j += 1; + } + + return [ + newWords.filter((w) => w.length > 0), + newTokens.filter((t) => t.length > 0), + newIndices.filter((t) => t.length > 0), + ]; +} + +function combineTokensIntoWords( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, + language = 'english', +): [string[], number[][], number[][]] { + let words: string[]; + let wordTokens: number[][]; + let tokenIndices: number[][]; + + if (['chinese', 'japanese', 'thai', 'lao', 'myanmar', 'zh', 'ja', 'th', 'lo', 'my'].includes(language)) { + [words, wordTokens, tokenIndices] = splitTokensOnUnicode(tokenizer, tokens); + } else { + [words, wordTokens, tokenIndices] = splitTokensOnSpaces(tokenizer, tokens, eosTokenId); + } + + return mergePunctuations(words, wordTokens, tokenIndices); +} + +export function extractTokenStartTimestamps(input: { + crossAttentions: Record; + decoderLayers: number; + alignmentHeads: Array<[number, number]>; + numFrames: number; + numInputIds: number; + timePrecision?: number; + sequenceLength: number; +}): number[] { + const { + crossAttentions, + decoderLayers, + alignmentHeads, + numFrames, + numInputIds, + timePrecision = 0.02, + sequenceLength, + } = input; + + const frameCount = Math.max(1, numFrames); + const perLayer: Float32Array[] = []; + for (let layer = 0; layer < decoderLayers; layer += 1) { + const key = `cross_attentions.${layer}`; + const tensor = crossAttentions[key]; + if (!tensor) continue; + perLayer[layer] = tensor.data as Float32Array; + } + + const selected: Float32Array[] = []; + let seqLen = 0; + let attnFrames = 0; + for (const [layer, head] of alignmentHeads) { + const flat = perLayer[layer]; + if (!flat) continue; + const layerTensor = crossAttentions[`cross_attentions.${layer}`]; + if (!layerTensor || layerTensor.dims.length < 4) continue; + const [, numHeads, currentSeqLen, currentFrames] = layerTensor.dims; + if (head >= numHeads) continue; + seqLen = currentSeqLen; + attnFrames = Math.min(currentFrames, frameCount); + const headSlice = new Float32Array(seqLen * attnFrames); + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < attnFrames; f += 1) { + const flatIndex = (((head * currentSeqLen) + s) * currentFrames) + f; + headSlice[(s * attnFrames) + f] = flat[flatIndex] ?? 0; + } + } + selected.push(headSlice); + } + + if (!selected.length || seqLen === 0 || attnFrames === 0) { + return new Array(sequenceLength).fill(0); + } + + const normalizedHeads = selected.map((headData) => { + const means = new Float32Array(attnFrames); + const stds = new Float32Array(attnFrames); + + for (let f = 0; f < attnFrames; f += 1) { + let sum = 0; + for (let s = 0; s < seqLen; s += 1) sum += headData[(s * attnFrames) + f]; + const mean = sum / seqLen; + means[f] = mean; + let varSum = 0; + for (let s = 0; s < seqLen; s += 1) { + const d = headData[(s * attnFrames) + f] - mean; + varSum += d * d; + } + stds[f] = Math.sqrt(varSum / seqLen) || 1; + } + + const out = new Float32Array(headData.length); + for (let s = 0; s < seqLen; s += 1) { + const row = new Float32Array(attnFrames); + for (let f = 0; f < attnFrames; f += 1) { + row[f] = (headData[(s * attnFrames) + f] - means[f]) / stds[f]; + } + const filtered = medianFilter(row, 7); + out.set(filtered, s * attnFrames); + } + return out; + }); + + const croppedRows = Math.max(0, seqLen - numInputIds); + if (croppedRows === 0) return new Array(sequenceLength).fill(0); + + const matrix: Float32Array[] = Array.from({ length: croppedRows }, () => new Float32Array(attnFrames)); + for (const headData of normalizedHeads) { + for (let r = 0; r < croppedRows; r += 1) { + const srcRow = r + numInputIds; + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] += headData[(srcRow * attnFrames) + f]; + } + } + } + + const scale = 1 / normalizedHeads.length; + for (let r = 0; r < croppedRows; r += 1) { + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] = -matrix[r][f] * scale; + } + } + + const [textIndices, timeIndices] = dynamicTimeWarping(matrix, croppedRows, attnFrames); + const jumps = new Array(textIndices.length).fill(false); + for (let i = 0; i < textIndices.length; i += 1) { + jumps[i] = i === 0 ? true : textIndices[i] !== textIndices[i - 1]; + } + + const jumpTimes: number[] = []; + for (let i = 0; i < jumps.length; i += 1) { + if (jumps[i]) jumpTimes.push(timeIndices[i] * timePrecision); + } + + const timestamps = new Array(sequenceLength).fill(0); + for (let i = 0; i < numInputIds && i < timestamps.length; i += 1) timestamps[i] = 0; + for (let i = 0; i < jumpTimes.length && (numInputIds + i) < timestamps.length; i += 1) { + timestamps[numInputIds + i] = jumpTimes[i]; + } + if (timestamps.length > 0 && jumpTimes.length > 0) { + timestamps[timestamps.length - 1] = jumpTimes[jumpTimes.length - 1]; + } + return timestamps; +} + +export function buildWordsFromTimestampedTokens(input: { + tokens: number[]; + tokenStartTimestamps: number[]; + tokenizer: Pick; + eosTokenId: number; + promptLength: number; + timestampBeginTokenId: number; + timePrecision?: number; + language?: string; +}): WhisperWordTiming[] { + const { + tokens, + tokenStartTimestamps, + tokenizer, + eosTokenId, + promptLength, + timestampBeginTokenId, + timePrecision = 0.02, + language = 'english', + } = input; + + const limit = Math.min(tokens.length, tokenStartTimestamps.length); + const tokenRanges: TokenTimestamp[] = []; + for (let i = 0; i < limit; i += 1) { + const start = tokenStartTimestamps[i] ?? 0; + const end = i + 1 < limit ? (tokenStartTimestamps[i + 1] ?? (start + timePrecision)) : (start + timePrecision); + tokenRanges.push([start, Math.max(start, end)]); + } + + const words: WhisperWordTiming[] = []; + let segmentStart: number | null = null; + let textTokens: number[] = []; + let textRanges: TokenTimestamp[] = []; + + const flushSegment = (segmentEnd: number | null) => { + if (!textTokens.length) return; + const [wordTexts, , tokenIndices] = combineTokensIntoWords(tokenizer, textTokens, eosTokenId, language); + for (let i = 0; i < wordTexts.length; i += 1) { + const indices = tokenIndices[i]; + if (!indices.length) continue; + const start = textRanges[indices[0]]?.[0] ?? segmentStart ?? 0; + const end = textRanges[indices[indices.length - 1]]?.[1] ?? segmentEnd ?? start; + const clampedStart = segmentStart == null ? start : Math.max(segmentStart, start); + const clampedEndBase = segmentEnd == null ? end : Math.min(segmentEnd, end); + const clampedEnd = Math.max( + clampedStart + (clampedEndBase <= clampedStart ? timePrecision : 0), + clampedEndBase, + ); + words.push({ + word: wordTexts[i].trim(), + startSec: round2(clampedStart), + endSec: round2(clampedEnd), + }); + } + textTokens = []; + textRanges = []; + }; + + for (let i = promptLength; i < limit; i += 1) { + const token = tokens[i]; + if (token === eosTokenId) break; + + if (token >= timestampBeginTokenId) { + const ts = (token - timestampBeginTokenId) * timePrecision; + if (segmentStart == null) { + segmentStart = ts; + } else { + flushSegment(ts); + segmentStart = ts; + } + continue; + } + + textTokens.push(token); + textRanges.push(tokenRanges[i]); + } + + flushSegment(null); + return words.filter((w) => w.word.length > 0); +} diff --git a/compute/core/tsconfig.json b/compute/core/tsconfig.json new file mode 100644 index 0000000..3e68ae5 --- /dev/null +++ b/compute/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/compute/worker/.env.example b/compute/worker/.env.example new file mode 100644 index 0000000..4809e98 --- /dev/null +++ b/compute/worker/.env.example @@ -0,0 +1,25 @@ +# Compute worker bind +COMPUTE_WORKER_HOST=0.0.0.0 +COMPUTE_WORKER_PORT=8081 +COMPUTE_LOG_FORMAT=pretty +# COMPUTE_LOG_LEVEL=info + +# App <-> worker auth +COMPUTE_WORKER_TOKEN=local-compute-token + +# Redis/BullMQ +REDIS_URL=redis://redis:6379 + +# Shared object storage (must be reachable from worker) +S3_BUCKET=openreader-documents +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=devkey +S3_SECRET_ACCESS_KEY=devsecret +S3_PREFIX=openreader +# Optional for non-AWS S3-compatible endpoints: +S3_ENDPOINT=http://host.docker.internal:8333 +S3_FORCE_PATH_STYLE=true + +# Queue + execution tuning +COMPUTE_QUEUE_MAX_DEPTH=64 +COMPUTE_PREWARM_MODELS=true diff --git a/compute/worker/Dockerfile b/compute/worker/Dockerfile new file mode 100644 index 0000000..cb0c4a5 --- /dev/null +++ b/compute/worker/Dockerfile @@ -0,0 +1,20 @@ +FROM node:lts + +RUN npm install -g pnpm@11.1.2 + +WORKDIR /workspace + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY compute/worker/package.json compute/worker/package.json +COPY compute/core/package.json compute/core/package.json + +RUN pnpm install --frozen-lockfile + +COPY . . + +ENV COMPUTE_WORKER_HOST=0.0.0.0 +ENV COMPUTE_WORKER_PORT=8081 + +EXPOSE 8081 + +CMD ["pnpm", "--filter", "@openreader/compute-worker", "dev"] diff --git a/compute/worker/docker-compose.yml b/compute/worker/docker-compose.yml new file mode 100644 index 0000000..067b2c6 --- /dev/null +++ b/compute/worker/docker-compose.yml @@ -0,0 +1,41 @@ +services: + redis: + image: redis:7-alpine + container_name: openreader-compute-redis + ports: + - "6379:6379" + + compute-worker: + build: + context: ../.. + dockerfile: compute/worker/Dockerfile + container_name: openreader-compute-worker + depends_on: + - redis + env_file: + - ./.env + environment: + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0} + COMPUTE_WORKER_PORT: ${COMPUTE_WORKER_PORT:-8081} + COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token} + S3_PREFIX: ${S3_PREFIX:-openreader} + COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-true} + ports: + - "8081:8081" + develop: + watch: + - action: sync+restart + path: . + target: /workspace/compute/worker + - action: sync+restart + path: ../../compute/core + target: /workspace/compute/core + - action: rebuild + path: ./package.json + - action: rebuild + path: ../../compute/core/package.json + - action: rebuild + path: ../../pnpm-lock.yaml + - action: rebuild + path: ../../pnpm-workspace.yaml diff --git a/compute/worker/package.json b/compute/worker/package.json new file mode 100644 index 0000000..a904a29 --- /dev/null +++ b/compute/worker/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openreader/compute-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1045.0", + "@openreader/compute-core": "workspace:*", + "bullmq": "^5.61.2", + "fastify": "^5.6.2", + "ioredis": "^5.8.2", + "pino": "^9.14.0", + "pino-pretty": "^13.1.2", + "zod": "^4.1.12" + }, + "devDependencies": { + "tsx": "^4.20.6" + } +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts new file mode 100644 index 0000000..8226dc3 --- /dev/null +++ b/compute/worker/src/server.ts @@ -0,0 +1,432 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import { Queue, Worker, type Job, type JobsOptions } from 'bullmq'; +import IORedis from 'ioredis'; +import { z } from 'zod'; +import { + ALIGN_QUEUE_NAME, + PDF_LAYOUT_QUEUE_NAME, + ensureComputeModels, + runPdfLayoutFromPdfBuffer, + runWhisperAlignmentFromAudioBuffer, + type PdfLayoutJobRequest, + type PdfLayoutJobResult, + type WhisperAlignJobRequest, + type WhisperAlignJobResult, + type WorkerJobStatusResponse, +} from '@openreader/compute-core'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function readIntEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +function parseBoolEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const normalized = raw.toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function buildLoggerConfig(): boolean | Record { + const format = (process.env.COMPUTE_LOG_FORMAT?.trim().toLowerCase() || 'pretty'); + if (format === 'json') return true; + return { + level: process.env.COMPUTE_LOG_LEVEL?.trim() || 'info', + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + }; +} + +function normalizeS3Prefix(prefix: string | undefined): string { + const value = (prefix || 'openreader').trim(); + return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader'; +} + +function buildS3Client(): S3Client { + const bucket = requireEnv('S3_BUCKET'); + const region = requireEnv('S3_REGION'); + const accessKeyId = requireEnv('S3_ACCESS_KEY_ID'); + const secretAccessKey = requireEnv('S3_SECRET_ACCESS_KEY'); + const endpoint = process.env.S3_ENDPOINT?.trim() || undefined; + const forcePathStyle = parseBoolEnv('S3_FORCE_PATH_STYLE', false); + + void bucket; + + return new S3Client({ + region, + endpoint, + forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId, + secretAccessKey, + }, + }); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + if (typeof body === 'object' && body !== null && 'on' in body) { + const stream = body as NodeJS.ReadableStream; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) chunks.push(chunk); + else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk)); + else chunks.push(Buffer.from(chunk as Uint8Array)); + } + return Buffer.concat(chunks); + } + throw new Error('Unsupported S3 response body type'); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function parseRetryAfterSeconds(raw: string | undefined): number { + const parsed = Number(raw ?? ''); + if (!Number.isFinite(parsed) || parsed <= 0) return 2; + return Math.max(1, Math.floor(parsed)); +} + +function isAuthed(request: FastifyRequest, expectedToken: string): boolean { + const auth = request.headers.authorization; + if (!auth?.startsWith('Bearer ')) return false; + const token = auth.slice('Bearer '.length).trim(); + return token === expectedToken; +} + +const alignSchema = z.object({ + text: z.string().trim().min(1), + lang: z.string().trim().min(1).max(16).optional(), + cacheKey: z.string().trim().min(1).max(256).optional(), + audioObjectKey: z.string().trim().min(1).max(2048), +}); + +const layoutSchema = z.object({ + documentId: z.string().trim().min(1), + namespace: z.string().trim().min(1).max(128).nullable(), + documentObjectKey: z.string().trim().min(1).max(2048), +}); + +function mapJobState(job: Job): WorkerJobStatusResponse { + if (job.failedReason) { + return { + status: 'failed', + error: { + message: job.failedReason || 'Worker job failed', + }, + }; + } + if (typeof job.returnvalue !== 'undefined' && job.finishedOn) { + return { + status: 'succeeded', + result: job.returnvalue as Result, + }; + } + + if (job.processedOn) return { status: 'running' }; + return { status: 'queued' }; +} + +async function getQueueDepth(queue: Queue): Promise { + const counts = await queue.getJobCounts('waiting', 'active', 'prioritized', 'delayed'); + return (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.prioritized ?? 0) + (counts.delayed ?? 0); +} + +async function main(): Promise { + const port = readIntEnv('COMPUTE_WORKER_PORT', 8081); + const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; + const workerToken = requireEnv('COMPUTE_WORKER_TOKEN'); + const redisUrl = requireEnv('REDIS_URL'); + const queueMaxDepth = readIntEnv('COMPUTE_QUEUE_MAX_DEPTH', 64); + const retryAfterSec = parseRetryAfterSeconds(process.env.COMPUTE_QUEUE_RETRY_AFTER_SEC); + + const whisperConcurrency = readIntEnv('COMPUTE_WHISPER_CONCURRENCY', 1); + const pdfConcurrency = readIntEnv('COMPUTE_PDF_CONCURRENCY', 2); + const whisperTimeoutMs = readIntEnv('COMPUTE_WHISPER_TIMEOUT_MS', 30_000); + const pdfTimeoutMs = readIntEnv('COMPUTE_PDF_TIMEOUT_MS', 90_000); + const attempts = readIntEnv('COMPUTE_JOB_ATTEMPTS', 2); + const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); + + const redis = new IORedis(redisUrl, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); + + const queueDefaults: JobsOptions = { + attempts, + backoff: { + type: 'exponential', + delay: 500, + }, + removeOnComplete: { + age: 60 * 60, + count: 1000, + }, + removeOnFail: { + age: 24 * 60 * 60, + count: 5000, + }, + }; + + const alignQueue = new Queue(ALIGN_QUEUE_NAME, { + connection: redis, + defaultJobOptions: queueDefaults, + }); + const layoutQueue = new Queue(PDF_LAYOUT_QUEUE_NAME, { + connection: redis, + defaultJobOptions: queueDefaults, + }); + + const s3 = buildS3Client(); + const s3Bucket = requireEnv('S3_BUCKET'); + const s3Prefix = normalizeS3Prefix(process.env.S3_PREFIX); + + const ensureSafeKey = (key: string): string => { + const trimmed = key.trim(); + if (!trimmed.startsWith(`${s3Prefix}/`)) { + throw new Error('Object key prefix mismatch'); + } + return trimmed; + }; + + const readObjectByKey = async (key: string): Promise => { + const safeKey = ensureSafeKey(key); + const response = await s3.send(new GetObjectCommand({ + Bucket: s3Bucket, + Key: safeKey, + })); + const bytes = await bodyToBuffer(response.Body); + return toArrayBuffer(new Uint8Array(bytes)); + }; + + const alignWorker = new Worker( + ALIGN_QUEUE_NAME, + async (job) => { + const parsed = alignSchema.parse(job.data); + const audioBuffer = await readObjectByKey(parsed.audioObjectKey); + return withTimeout( + runWhisperAlignmentFromAudioBuffer({ + audioBuffer, + text: parsed.text, + cacheKey: parsed.cacheKey, + lang: parsed.lang, + }), + whisperTimeoutMs, + 'whisper alignment job', + ); + }, + { + connection: redis, + concurrency: whisperConcurrency, + }, + ); + + const layoutWorker = new Worker( + PDF_LAYOUT_QUEUE_NAME, + async (job) => { + const parsed = layoutSchema.parse(job.data); + const pdfBytes = await readObjectByKey(parsed.documentObjectKey); + return withTimeout( + runPdfLayoutFromPdfBuffer({ + documentId: parsed.documentId, + pdfBytes, + }), + pdfTimeoutMs, + 'pdf layout job', + ); + }, + { + connection: redis, + concurrency: pdfConcurrency, + }, + ); + + alignWorker.on('failed', (job, err) => { + console.error('[compute-worker] align job failed', { + jobId: job?.id, + error: err.message, + }); + }); + + layoutWorker.on('failed', (job, err) => { + console.error('[compute-worker] layout job failed', { + jobId: job?.id, + error: err.message, + }); + }); + + if (prewarmModels) { + await ensureComputeModels(); + } + + const app = Fastify({ + logger: buildLoggerConfig(), + }); + + app.addHook('onRequest', async (request, reply) => { + const path = request.url.split('?')[0] ?? request.url; + if (path === '/health/live' || path === '/health/ready') return; + if (!isAuthed(request, workerToken)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + return; + }); + + app.get('/health/live', async () => ({ ok: true })); + + app.get('/health/ready', async (_request, reply) => { + try { + await redis.ping(); + return { ok: true }; + } catch (error) { + reply.code(503); + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + const rejectIfSaturated = async (queue: Queue, reply: FastifyReply): Promise => { + const depth = await getQueueDepth(queue); + if (depth < queueMaxDepth) return false; + reply.header('Retry-After', String(retryAfterSec)); + reply.code(429).send({ + error: 'Queue is saturated', + retryAfterSeconds: retryAfterSec, + queueDepth: depth, + queueMaxDepth, + }); + return true; + }; + + app.post('/align/whisper/jobs', async (request, reply) => { + const parsed = alignSchema.safeParse(request.body); + if (!parsed.success) { + reply.code(400); + return { + error: 'Invalid request body', + issues: parsed.error.issues, + }; + } + if (await rejectIfSaturated(alignQueue, reply)) return; + + const job = await alignQueue.add('align', parsed.data); + reply.code(202); + return { jobId: String(job.id) }; + }); + + app.get('/align/whisper/jobs/:jobId', async (request, reply) => { + const params = z.object({ jobId: z.string().trim().min(1) }).safeParse(request.params); + if (!params.success) { + reply.code(400); + return { error: 'Invalid job id' }; + } + const job = await alignQueue.getJob(params.data.jobId); + if (!job) { + reply.code(404); + return { error: 'Job not found' }; + } + return mapJobState(job); + }); + + app.post('/layout/pdf/jobs', async (request, reply) => { + const parsed = layoutSchema.safeParse(request.body); + if (!parsed.success) { + reply.code(400); + return { + error: 'Invalid request body', + issues: parsed.error.issues, + }; + } + if (await rejectIfSaturated(layoutQueue, reply)) return; + + const job = await layoutQueue.add('layout', parsed.data); + reply.code(202); + return { jobId: String(job.id) }; + }); + + app.get('/layout/pdf/jobs/:jobId', async (request, reply) => { + const params = z.object({ jobId: z.string().trim().min(1) }).safeParse(request.params); + if (!params.success) { + reply.code(400); + return { error: 'Invalid job id' }; + } + const job = await layoutQueue.getJob(params.data.jobId); + if (!job) { + reply.code(404); + return { error: 'Job not found' }; + } + return mapJobState(job); + }); + + const close = async (): Promise => { + await app.close(); + await Promise.allSettled([ + alignWorker.close(), + layoutWorker.close(), + alignQueue.close(), + layoutQueue.close(), + redis.quit(), + ]); + }; + + process.once('SIGINT', () => { + void close().finally(() => process.exit(0)); + }); + process.once('SIGTERM', () => { + void close().finally(() => process.exit(0)); + }); + + await app.listen({ host, port }); + app.log.info({ host, port }, 'compute worker listening'); +} + +void main().catch((error) => { + console.error('[compute-worker] fatal startup error', error); + process.exit(1); +}); diff --git a/compute/worker/tsconfig.json b/compute/worker/tsconfig.json new file mode 100644 index 0000000..3e68ae5 --- /dev/null +++ b/compute/worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md new file mode 100644 index 0000000..af00ab2 --- /dev/null +++ b/docs-site/docs/deploy/compute-worker.md @@ -0,0 +1,64 @@ +--- +title: Compute Worker (Redis + BullMQ) +--- + +Use this guide for `COMPUTE_MODE=worker` deployments where heavy compute runs outside the Next.js app server. + +## Overview + +The compute worker handles: + +- Whisper word alignment (`/align/whisper/jobs`) +- PDF layout parsing (`/layout/pdf/jobs`) + +The app server enqueues jobs and polls status. Queue durability and retries are backed by Redis + BullMQ. + +## Published image + +- App server image: `ghcr.io/richardr1126/openreader` +- Compute worker image: `ghcr.io/richardr1126/openreader-compute-worker` + +## Worker environment variables + +Required: + +- `COMPUTE_WORKER_TOKEN`: bearer token expected by worker routes +- `REDIS_URL`: BullMQ Redis connection string +- `S3_BUCKET` +- `S3_REGION` +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` + +Common optional: + +- `S3_ENDPOINT` (for non-AWS S3-compatible storage) +- `S3_FORCE_PATH_STYLE=true` (for many S3-compatible providers) +- `S3_PREFIX=openreader` +- `COMPUTE_WORKER_HOST=0.0.0.0` +- `COMPUTE_WORKER_PORT=8081` +- `COMPUTE_LOG_FORMAT=pretty` (default) or `json` +- `COMPUTE_QUEUE_MAX_DEPTH=64` +- `COMPUTE_PREWARM_MODELS=true` + +## App server environment variables (worker mode) + +Set on the Next.js app server: + +```env +COMPUTE_MODE=worker +COMPUTE_WORKER_URL=http://:8081 +COMPUTE_WORKER_TOKEN= +``` + +`COMPUTE_MODE=worker` has no local fallback. If worker is unavailable, affected requests fail. + +## Production notes + +- Worker mode assumes shared object storage is reachable by both app server and worker. +- Non-exposed embedded `weed mini` is not supported with external worker mode. +- Protect `COMPUTE_WORKER_TOKEN` and avoid exposing worker routes publicly without auth. + +## Health endpoints + +- `GET /health/live` +- `GET /health/ready` diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7ab46c5..80c07c7 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -124,6 +124,40 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i +
+External compute worker dev stack (optional) + +Use this when you want durable compute with Redis/BullMQ while keeping Next.js on native host `pnpm dev`. +Full worker deployment details are in [Compute Worker (Redis + BullMQ)](./compute-worker). + +Start only Redis + compute-worker via compose watch: + +```bash +docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch +# or: pnpm compute:dev:watch +``` + +`compute/worker/.env.example` contains a starter config. Copy it to `compute/worker/.env` and adjust values for your environment. + +Run the main app separately on the host: + +```bash +pnpm dev +``` + +For app server worker mode, set: + +```env +COMPUTE_MODE=worker +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= +``` + +Worker mode requires worker-reachable shared object storage (S3-compatible endpoint). +Non-exposed embedded `weed mini` is not a supported topology for external worker mode. + +
+ ## Steps ### Required flow @@ -206,6 +240,25 @@ S3_SECRET_ACCESS_KEY=your-secret-key # Optional for non-AWS providers: # S3_ENDPOINT=https://your-s3-compatible-endpoint # S3_FORCE_PATH_STYLE=true +``` + + + + +```env +API_BASE=http://host.docker.internal:8880/v1 +API_KEY=none +COMPUTE_MODE=worker +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= +USE_EMBEDDED_WEED_MINI=false +S3_BUCKET=your-bucket +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +# Optional for non-AWS providers: +# S3_ENDPOINT=https://your-s3-compatible-endpoint +# S3_FORCE_PATH_STYLE=true ``` diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index e6053fd..e9a1162 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -8,6 +8,8 @@ This guide covers deploying OpenReader to Vercel with external Postgres and S3-c - Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. - Audiobook routes work on Node.js serverless functions using `ffmpeg-static`. +- Heavy compute features (Whisper alignment + PDF layout parsing) work through `COMPUTE_MODE=worker` with an external compute worker service. +- For worker setup details and worker-specific env vars, see [Compute Worker (Redis + BullMQ)](./compute-worker). :::warning DOCX Conversion Limitation `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. @@ -36,12 +38,15 @@ AUTH_SECRET=... ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app # Heavy compute (recommended on Vercel in v1) -# local = requires native binaries/models in-process +# local = requires native binaries/models in-process (not recommended on Vercel) +# worker = external durable compute worker (recommended) # none = disable ONNX whisper alignment + PDF layout parsing -COMPUTE_MODE=none +COMPUTE_MODE=worker +COMPUTE_WORKER_URL=https://your-compute-worker.example.com +COMPUTE_WORKER_TOKEN=... # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) -API_KEY=your_replicate_key +# API_KEY=your_replicate_key # API_BASE only needed for OpenAI-compatible self-hosted providers ``` @@ -129,4 +134,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If you run with local compute (`COMPUTE_MODE=local`) outside Vercel, verify word highlighting timestamps on a TTS run. +4. Verify worker-backed word highlighting and PDF parsing in `COMPUTE_MODE=worker`. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 933cc29..97a555a 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -22,6 +22,12 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds. `4.19` introduced intermittent `InternalError` responses on S3 `PutObject` in our upload flow. ::: +## Published images + +- App server: `ghcr.io/richardr1126/openreader:latest` +- Compute worker (Optional): `ghcr.io/richardr1126/openreader-compute-worker:latest` +- Legacy app alias: `ghcr.io/richardr1126/openreader-webui:latest` + ## 1. Start the Docker container @@ -135,6 +141,7 @@ Visit [http://localhost:3003](http://localhost:3003) after startup. ## 3. Update Docker image Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias. +For external compute mode image details, see [Compute Worker (Redis + BullMQ)](./deploy/compute-worker). ```bash docker stop openreader || true && \ diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index f04133b..3053452 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -54,8 +54,8 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | | `COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing | -| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | -| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required when `COMPUTE_MODE=worker`; base URL for external compute worker | +| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Required bearer token for external compute worker auth | | `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | | `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped int8 downloads | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -357,22 +357,26 @@ Selects the backend for heavy compute features (ONNX word alignment + PDF layout - Default: `local` - Supported in v1: - `local`: run compute in-process on the app server + - `worker`: enqueue async jobs in an external durable compute worker (Redis + BullMQ) - `none`: disable these features cleanly -- `worker` is reserved for a future external worker backend and currently fails fast at startup if selected +- `worker` requires `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` +- `worker` assumes the external worker can directly reach shared object storage (S3-compatible endpoint) +- `worker` is not compatible with non-exposed embedded `weed mini` storage topologies +- Worker service env vars are documented in [Compute Worker (Redis + BullMQ)](../deploy/compute-worker) ### COMPUTE_WORKER_URL -Reserved for future external compute worker mode. +Base URL for external compute worker mode. -- Used only when `COMPUTE_MODE=worker` (not implemented in v1) -- Leave unset in v1 +- Used only when `COMPUTE_MODE=worker` +- Example: `http://localhost:8081` ### COMPUTE_WORKER_TOKEN -Reserved bearer token for future external compute worker mode. +Bearer token for external compute worker auth. -- Used only when `COMPUTE_MODE=worker` (not implemented in v1) -- Leave unset in v1 +- Used only when `COMPUTE_MODE=worker` +- Must match worker service `COMPUTE_WORKER_TOKEN` ### PDF_LAYOUT_MODEL_BASE_URL diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index b6dc596..7534a86 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -4,9 +4,10 @@ title: Stack ## Framework -- [Next.js](https://nextjs.org/) 15 (App Router) +- [Next.js](https://nextjs.org/) 15 (App Router, Turbopack in dev) - [React](https://react.dev/) 19 - [TypeScript](https://www.typescriptlang.org/) +- [pnpm](https://pnpm.io/) workspaces monorepo ## Containerization and runtime @@ -17,24 +18,48 @@ title: Stack - UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) - Interactions: `react-dnd`, `react-dropzone` +- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5) - Authentication: [Better Auth](https://www.better-auth.com/) client SDK - Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB) +- Audio playback: [Howler.js](https://howlerjs.com/) +- Notifications: `react-hot-toast` - Document rendering: - PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/) - EPUB: [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/) - Markdown/Text: [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm) - Text preprocessing/matching: [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr) +- Analytics: [Vercel Analytics](https://vercel.com/analytics) ## Next.js server - APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying - State sync: request-based today (not realtime push updates) -- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters +- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters with anonymous session support - Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) - App tables are manually maintained in Drizzle schema files - Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 -- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, built-in ONNX Whisper (`onnx-community/whisper-base_timestamped` int8) for word timestamps +- TTS providers: OpenAI-compatible API (`openai` SDK), [Replicate](https://replicate.com/) (`replicate` client), DeepInfra, and custom OpenAI-compatible endpoints — credentials are encrypted at rest +- Audio pipeline: [ffmpeg](https://ffmpeg.org/) (`ffmpeg-static`) for audiobook assembly, `archiver` for export packaging +- Utilities: `lru-cache` for in-process caching, `fast-xml-parser` for EPUB/XML parsing, `uuid` for identifier generation, `zod` for schema validation + +## External compute worker (optional) + +Monorepo packages under `compute/`: + +- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared between local and worker modes + - ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers` + - Whisper alignment: `onnx-community/whisper-base_timestamped` (int8) for word-level timestamps + - PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing + - PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization + - Utilities: `jszip`, `ffmpeg-static` +- **`@openreader/compute-worker`** — standalone Node.js worker service + - HTTP server: [Fastify](https://fastify.dev/) v5 + - Job queue: [BullMQ](https://bullmq.io/) + [ioredis](https://github.com/redis/ioredis) (queues: `whisper-align`, `pdf-layout`) + - Storage: AWS SDK v3 S3 client for reading/writing blobs + - Logging: [Pino](https://getpino.io/) + - Validation: [Zod](https://zod.dev/) +- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process), `worker` (remote queue via HTTP + Redis), or disabled ## Tooling and testing @@ -42,3 +67,4 @@ title: Stack - TypeScript - [Playwright](https://playwright.dev/) end-to-end tests - Drizzle migration/generation scripts +- [Docusaurus](https://docusaurus.io/) documentation site (`docs-site/`) diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index b433750..fe5d1a3 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -53,7 +53,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: '🚀 Deploy', - items: ['deploy/local-development', 'deploy/vercel-deployment'], + items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'], }, { type: 'category', diff --git a/next.config.ts b/next.config.ts index 0773b73..7e54e19 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,13 +15,16 @@ const securityHeaders = [ }, ]; -const computeMode = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); -const computeDisabled = computeMode === 'none'; +const computeModeRaw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); +const computeMode = computeModeRaw === 'none' || computeModeRaw === 'worker' || computeModeRaw === 'local' + ? computeModeRaw + : 'local'; +const computeLocal = computeMode === 'local'; const serverExternalPackages = [ '@napi-rs/canvas', 'ffmpeg-static', 'better-sqlite3', - ...(computeDisabled ? [] : ['onnxruntime-node', '@huggingface/tokenizers']), + ...(computeLocal ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), ]; const nextConfig: NextConfig = { @@ -39,6 +42,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, + transpilePackages: ['@openreader/compute-core'], serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ @@ -60,7 +64,7 @@ const nextConfig: NextConfig = { './node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs', ], }, - ...(computeDisabled + ...(!computeLocal ? { outputFileTracingExcludes: { '/*': [ diff --git a/package.json b/package.json index 115a93e..838d117 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ "docs:build": "pnpm --dir docs-site build", "docs:serve": "pnpm --dir docs-site serve", "docs:version": "pnpm --dir docs-site docusaurus docs:version", - "docs:clear": "pnpm --dir docs-site clear" + "docs:clear": "pnpm --dir docs-site clear", + "compute:worker:dev": "pnpm --filter @openreader/compute-worker dev", + "compute:worker:start": "pnpm --filter @openreader/compute-worker start", + "compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build", + "compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch" }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2c265a..49947fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,58 @@ importers: specifier: ^5.9.3 version: 5.9.3 + compute/core: + dependencies: + '@huggingface/tokenizers': + specifier: ^0.1.3 + version: 0.1.3 + '@napi-rs/canvas': + specifier: ^0.1.100 + version: 0.1.100 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + onnxruntime-node: + specifier: ^1.26.0 + version: 1.26.0 + pdfjs-dist: + specifier: 4.8.69 + version: 4.8.69 + + compute/worker: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1045.0 + version: 3.1045.0 + '@openreader/compute-core': + specifier: workspace:* + version: link:../core + bullmq: + specifier: ^5.61.2 + version: 5.76.10 + fastify: + specifier: ^5.6.2 + version: 5.8.5 + ioredis: + specifier: ^5.8.2 + version: 5.10.1 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.2 + version: 13.1.3 + zod: + specifier: ^4.1.12 + version: 4.4.3 + devDependencies: + tsx: + specifier: ^4.20.6 + version: 4.21.0 + packages: '@alloc/quick-lru@5.2.0': @@ -948,6 +1000,24 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1161,6 +1231,9 @@ packages: '@internationalized/string@3.2.8': resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1178,6 +1251,36 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@napi-rs/canvas-android-arm64@0.1.100': resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} @@ -1345,6 +1448,9 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1393,6 +1499,7 @@ packages: '@smithy/core@3.24.1': resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 '@smithy/credential-provider-imds@4.3.1': resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} @@ -1853,6 +1960,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1871,9 +1981,20 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1961,6 +2082,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -1969,6 +2094,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + axe-core@4.11.4: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} @@ -2153,6 +2281,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bullmq@5.76.10: + resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==} + engines: {node: '>=12.22.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2216,6 +2348,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmpstr@3.3.0: resolution: {integrity: sha512-of06NSLK24YPV89qYmlJQxENZF43BtIGlXmrFaUIdkuBMayIckFX9NjRB6PPfkhxpLIOb6LhiTe/P5QoBu6r3w==} engines: {node: '>=18.0.0'} @@ -2227,6 +2363,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2249,6 +2388,10 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -2264,6 +2407,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2295,6 +2442,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2337,6 +2487,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2717,6 +2871,12 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2734,9 +2894,21 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -2748,6 +2920,9 @@ packages: resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2779,6 +2954,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2917,6 +3096,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -2968,6 +3150,14 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3122,6 +3312,10 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3132,9 +3326,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3177,6 +3377,9 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3191,6 +3394,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3211,6 +3420,10 @@ packages: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + make-cancellable-promise@1.3.2: resolution: {integrity: sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==} @@ -3408,6 +3621,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@2.0.1: + resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3459,6 +3679,9 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -3466,6 +3689,10 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3506,6 +3733,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3639,6 +3870,23 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3741,6 +3989,12 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -3765,6 +4019,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -3868,6 +4125,18 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -3895,6 +4164,10 @@ packages: resolution: {integrity: sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w==} engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3912,10 +4185,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -3940,9 +4220,20 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3956,6 +4247,9 @@ packages: resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -4012,6 +4306,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4033,6 +4330,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4100,6 +4400,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -4169,6 +4473,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4180,6 +4487,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5181,6 +5492,29 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5343,6 +5677,8 @@ snapshots: dependencies: '@swc/helpers': 0.5.21 + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5366,6 +5702,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -5472,6 +5826,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -5989,6 +6345,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -6003,6 +6361,10 @@ snapshots: transitivePeerDependencies: - supports-color + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6010,6 +6372,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -6134,12 +6503,19 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + attr-accept@2.2.5: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + axe-core@4.11.4: {} axobject-query@4.1.0: {} @@ -6276,6 +6652,17 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.76.10: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 2.0.1 + node-abort-controller: 3.1.1 + semver: 7.8.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6340,6 +6727,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmpstr@3.3.0: {} color-convert@2.0.1: @@ -6348,6 +6737,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} commander@4.1.1: {} @@ -6375,6 +6766,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + cookie@1.1.1: {} + core-js@3.49.0: {} core-util-is@1.0.3: {} @@ -6386,6 +6779,10 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6421,6 +6818,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dateformat@4.6.3: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6455,6 +6854,8 @@ snapshots: defu@6.1.7: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -6971,6 +7372,10 @@ snapshots: extend@3.0.2: {} + fast-copy@4.0.3: {} + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -6993,8 +7398,25 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -7015,6 +7437,24 @@ snapshots: strnum: 2.3.0 xml-naming: 0.1.0 + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.0 + toad-cache: 3.7.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7046,6 +7486,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7207,6 +7653,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + help-me@5.0.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -7253,6 +7701,22 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@2.4.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7411,6 +7875,8 @@ snapshots: jose@6.2.3: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -7419,8 +7885,14 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -7470,6 +7942,12 @@ snapshots: dependencies: immediate: 3.0.6 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -7482,6 +7960,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} lodash@4.18.1: {} @@ -7496,6 +7978,8 @@ snapshots: lru-cache@11.3.6: {} + luxon@3.7.2: {} + make-cancellable-promise@1.3.2: {} make-event-props@1.6.2: {} @@ -7891,6 +8375,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@2.0.1: + optionalDependencies: + msgpackr-extract: 3.0.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -7937,6 +8437,8 @@ snapshots: dependencies: semver: 7.8.0 + node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: optional: true @@ -7947,6 +8449,11 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + normalize-path@3.0.0: {} object-assign@4.1.1: {} @@ -7993,6 +8500,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8118,6 +8627,46 @@ snapshots: pify@2.3.0: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + pirates@4.0.7: {} playwright-core@1.60.0: {} @@ -8208,6 +8757,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + process@0.11.10: {} progress@2.0.3: {} @@ -8229,6 +8782,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -8379,6 +8934,14 @@ snapshots: dependencies: picomatch: 2.3.2 + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux@4.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -8441,6 +9004,8 @@ snapshots: optionalDependencies: readable-stream: 4.7.0 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8461,8 +9026,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + rou3@0.7.12: {} run-parallel@1.2.0: @@ -8492,8 +9061,16 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.8.0: {} @@ -8502,6 +9079,8 @@ snapshots: dependencies: type-fest: 0.20.2 + set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -8604,6 +9183,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -8619,6 +9202,8 @@ snapshots: stable-hash@0.0.5: {} + standard-as-callback@2.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8722,6 +9307,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + strnum@2.3.0: {} style-to-js@1.1.21: @@ -8832,6 +9419,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tiny-invariant@1.3.3: {} tinyglobby@0.2.16: @@ -8843,6 +9434,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index caae007..67ae185 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - . + - compute/* allowBuilds: '@napi-rs/canvas': true diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index ad5bffa..aa80c1e 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -372,10 +372,8 @@ export async function POST(request: NextRequest) { // previously unavailable, retry alignment using the current segment text. if (!alignment) { try { - const audioBuffer = await getTtsSegmentAudioObject(existing.audioKey); - const whisperBytes = Uint8Array.from(audioBuffer); const aligned = (await getCompute().alignWords({ - audioBuffer: whisperBytes.buffer, + audioObjectKey: existing.audioKey, text: segment.text, })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; @@ -527,6 +525,7 @@ export async function POST(request: NextRequest) { const whisperBytes = Uint8Array.from(persistedBuffer); const aligned = (await getCompute().alignWords({ audioBuffer: whisperBytes.buffer, + audioObjectKey: audioKey, text: segment.text, })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index 8b211b2..d9f847d 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -1,17 +1,14 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; import { NoneComputeBackend } from '@/lib/server/compute/none'; import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; +import { WorkerComputeBackend } from '@/lib/server/compute/worker'; let backend: ComputeBackend | null = null; function createBackend(): ComputeBackend { const mode: ComputeMode = readComputeMode(); if (mode === 'none') return new NoneComputeBackend(); - if (mode === 'worker') { - throw new Error( - 'COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', - ); - } + if (mode === 'worker') return new WorkerComputeBackend(); // Intentionally lazy-load local compute so COMPUTE_MODE=none builds // can avoid tracing heavy ONNX dependencies. // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index a38bf6a..c1f1475 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -1,21 +1,40 @@ import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { getDocumentBlob } from '@/lib/server/documents/blobstore'; +import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; +import { + runPdfLayoutFromPdfBuffer, + runWhisperAlignmentFromAudioBuffer, +} from '@openreader/compute-core'; export class LocalComputeBackend implements ComputeBackend { readonly mode = 'local' as const; async alignWords(input: WhisperAlignInput): Promise { - const { alignAudioWithText } = await import('@/lib/server/whisper/alignment'); - const alignments = await alignAudioWithText( - input.audioBuffer, - input.text, - input.cacheKey, - { lang: input.lang }, - ); - return { alignments }; + let audioBuffer = input.audioBuffer ?? null; + if (!audioBuffer && input.audioObjectKey) { + const bytes = new Uint8Array(await getTtsSegmentAudioObject(input.audioObjectKey)); + audioBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + if (!audioBuffer) { + throw new Error('Local compute alignment requires audioBuffer or audioObjectKey'); + } + return runWhisperAlignmentFromAudioBuffer({ + audioBuffer, + text: input.text, + cacheKey: input.cacheKey, + lang: input.lang, + }); } async parsePdfLayout(input: PdfLayoutInput) { - const { parsePdf } = await import('@/lib/server/pdf-layout/parsePdf'); - return parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes }); + let pdfBytes = input.pdfBytes ?? null; + if (!pdfBytes && input.documentId && typeof input.namespace !== 'undefined') { + const bytes = new Uint8Array(await getDocumentBlob(input.documentId, input.namespace)); + pdfBytes = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + if (!pdfBytes) { + throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); + } + return (await runPdfLayoutFromPdfBuffer({ documentId: input.documentId, pdfBytes })).parsed; } } diff --git a/src/lib/server/compute/mode.ts b/src/lib/server/compute/mode.ts index 5ebd349..42b066e 100644 --- a/src/lib/server/compute/mode.ts +++ b/src/lib/server/compute/mode.ts @@ -7,10 +7,5 @@ export function readComputeMode(): ComputeMode { } export function isComputeModeAvailable(mode: ComputeMode): boolean { - if (mode === 'worker') { - throw new Error( - 'COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', - ); - } return mode !== 'none'; } diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index 3a411eb..2acea80 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -1,10 +1,10 @@ -import type { TTSAudioBuffer, TTSSentenceAlignment } from '@/types/tts'; -import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core'; export type ComputeMode = 'local' | 'worker' | 'none'; export interface WhisperAlignInput { - audioBuffer: TTSAudioBuffer; + audioBuffer?: TTSAudioBuffer; + audioObjectKey?: string; text: string; cacheKey?: string; lang?: string; @@ -16,7 +16,9 @@ export interface WhisperAlignResult { export interface PdfLayoutInput { documentId: string; - pdfBytes: ArrayBuffer; + namespace?: string | null; + documentObjectKey?: string; + pdfBytes?: ArrayBuffer; } export interface ComputeBackend { diff --git a/src/lib/server/compute/worker-contract.ts b/src/lib/server/compute/worker-contract.ts new file mode 100644 index 0000000..ec4df20 --- /dev/null +++ b/src/lib/server/compute/worker-contract.ts @@ -0,0 +1,11 @@ +export { + ALIGN_QUEUE_NAME, + PDF_LAYOUT_QUEUE_NAME, + type PdfLayoutJobRequest, + type PdfLayoutJobResult, + type WhisperAlignJobRequest, + type WhisperAlignJobResult, + type WorkerJobErrorShape, + type WorkerJobState, + type WorkerJobStatusResponse, +} from '@openreader/compute-core'; diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts new file mode 100644 index 0000000..79af3ba --- /dev/null +++ b/src/lib/server/compute/worker.ts @@ -0,0 +1,165 @@ +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import type { + PdfLayoutJobRequest, + PdfLayoutJobResult, + WhisperAlignJobRequest, + WhisperAlignJobResult, + WorkerJobStatusResponse, +} from '@/lib/server/compute/worker-contract'; + +class WorkerHttpError extends Error { + status: number; + retryAfterMs: number | null; + + constructor(message: string, status: number, retryAfterMs: number | null = null) { + super(message); + this.name = 'WorkerHttpError'; + this.status = status; + this.retryAfterMs = retryAfterMs; + } +} + +const DEFAULT_WAIT_TIMEOUT_MS = 45_000; +const DEFAULT_RETRIES = 2; +const POLL_INTERVAL_MS = 400; +const POLL_MAX_INTERVAL_MS = 1_500; + +function readRequiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required when COMPUTE_MODE=worker`); + return value; +} + +function parseRetryAfterMs(value: string | null): number | null { + if (!value) return null; + const asNum = Number(value); + if (Number.isFinite(asNum)) { + return Math.max(0, Math.floor(asNum * 1000)); + } + const when = Date.parse(value); + if (Number.isNaN(when)) return null; + return Math.max(0, when - Date.now()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldRetry(error: unknown): boolean { + if (error instanceof WorkerHttpError) { + return error.status === 429 || error.status === 502 || error.status === 503 || error.status === 504; + } + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return msg.includes('network') || msg.includes('timeout') || msg.includes('fetch failed'); + } + return false; +} + +async function withRetries(attempts: number, operation: () => Promise): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if (attempt === attempts - 1 || !shouldRetry(error)) break; + if (error instanceof WorkerHttpError && typeof error.retryAfterMs === 'number') { + await sleep(error.retryAfterMs); + } else { + await sleep((attempt + 1) * 250); + } + } + } + throw lastError instanceof Error ? lastError : new Error('Unknown worker compute failure'); +} + +export class WorkerComputeBackend implements ComputeBackend { + readonly mode = 'worker' as const; + private readonly baseUrl: string; + private readonly token: string; + private readonly waitTimeoutMs: number; + private readonly retries: number; + + constructor() { + this.baseUrl = readRequiredEnv('COMPUTE_WORKER_URL').replace(/\/+$/, ''); + this.token = readRequiredEnv('COMPUTE_WORKER_TOKEN'); + this.waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS; + this.retries = DEFAULT_RETRIES; + } + + async alignWords(input: WhisperAlignInput): Promise { + if (!input.audioObjectKey) { + throw new Error('Worker compute alignment requires audioObjectKey'); + } + const payload: WhisperAlignJobRequest = { + text: input.text, + lang: input.lang, + cacheKey: input.cacheKey, + audioObjectKey: input.audioObjectKey, + }; + + return withRetries(this.retries, async () => { + const { jobId } = await this.requestJson<{ jobId: string }>('POST', '/align/whisper/jobs', payload); + const status = await this.waitForJob(`/align/whisper/jobs/${encodeURIComponent(jobId)}`); + if (status.status !== 'succeeded' || !status.result) { + throw new Error(status.error?.message || 'Whisper worker job did not complete'); + } + return { alignments: status.result.alignments }; + }); + } + + async parsePdfLayout(input: PdfLayoutInput) { + if (!input.documentObjectKey) { + throw new Error('Worker compute PDF layout requires documentObjectKey'); + } + const payload: PdfLayoutJobRequest = { + documentId: input.documentId, + namespace: input.namespace ?? null, + documentObjectKey: input.documentObjectKey, + }; + return withRetries(this.retries, async () => { + const { jobId } = await this.requestJson<{ jobId: string }>('POST', '/layout/pdf/jobs', payload); + const status = await this.waitForJob(`/layout/pdf/jobs/${encodeURIComponent(jobId)}`); + if (status.status !== 'succeeded' || !status.result) { + throw new Error(status.error?.message || 'PDF layout worker job did not complete'); + } + return status.result.parsed; + }); + } + + private async requestJson(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}), + }, + ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), + }); + + if (!res.ok) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const detail = await res.text().catch(() => ''); + throw new WorkerHttpError( + `Worker request failed (${method} ${path}): ${res.status}${detail ? ` ${detail}` : ''}`, + res.status, + retryAfterMs, + ); + } + + return res.json() as Promise; + } + + private async waitForJob(path: string): Promise> { + const started = Date.now(); + let interval = POLL_INTERVAL_MS; + while ((Date.now() - started) < this.waitTimeoutMs) { + const status = await this.requestJson>('GET', path); + if (status.status === 'succeeded' || status.status === 'failed') return status; + await sleep(interval); + interval = Math.min(POLL_MAX_INTERVAL_MS, Math.floor(interval * 1.5)); + } + throw new Error(`Timed out waiting for worker job after ${this.waitTimeoutMs}ms`); + } +} diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index eab9f02..fba48e4 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { UnsupportedComputeError } from '@/lib/server/compute/types'; -import { getDocumentBlob, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; @@ -29,10 +29,10 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { .set({ parseStatus: 'running' }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); - const pdfBytes = await getDocumentBlob(input.documentId, input.namespace); const parsed = await getCompute().parsePdfLayout({ documentId: input.documentId, - pdfBytes: new Uint8Array(pdfBytes).buffer, + namespace: input.namespace, + documentObjectKey: documentKey(input.documentId, input.namespace), }); const parsedJson = Buffer.from(JSON.stringify(parsed)); diff --git a/tsconfig.json b/tsconfig.json index 3dfc4fb..00d1d04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@openreader/compute-core": ["./compute/core/src/index.ts"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From ea5611c882515c7108fec18f38b00347c7e4ade8 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 15:32:18 -0600 Subject: [PATCH 010/137] docs(intro): update highlights and backend details in docs and README Revise project highlights in README and introduction to emphasize ONNX-based PDF layout parsing (PP-DocLayoutV3), segment-based TTS playback, and flexible backend/storage options. Clarify compute modes and self-hosting features. Update TTS provider descriptions for accuracy and conciseness. Also expand tsconfig exclude list to omit compute worker sources from main build. --- README.md | 13 ++++++------- docs-site/docs/introduction.md | 31 +++++++++++-------------------- tsconfig.json | 8 +++++++- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5fc22dd..ee8d95a 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,13 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights -- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). -- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment in local mode (`COMPUTE_MODE=local`) or external worker mode (`COMPUTE_MODE=worker`). -- 🧱 **Layout-aware PDF parsing** (PP-DocLayoutV3 ONNX) with structured blocks for cleaner TTS/chaptering. -- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. -- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. +- 🧱 **Layout-aware PDF parsing** with PP-DocLayoutV3 (ONNX) — structured block detection, cross-page stitching, and geometry-based highlighting for precise read-along sync. +- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment — no external dependencies, runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable Redis-backed worker (`COMPUTE_MODE=worker`). +- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback. +- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. -- 🐳 **Self-host friendly** with Docker, optional auth, and automatic startup migrations. +- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync. +- 🐳 **Self-host friendly** — Docker (amd64/arm64), optional auth, and automatic startup migrations. ## 🚀 Start Here diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 53264c6..d8c3c67 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -12,28 +12,19 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c ## ✨ Highlights +- 🧱 **Layout-aware PDF Parsing** + - PP-DocLayoutV3 (ONNX) detects structured blocks with cross-page stitching and geometry-based highlighting for precise read-along sync and clean TTS segmentation +- ⏱️ **Word-by-word Highlighting** via ONNX Whisper alignment + - No external dependencies — runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable Redis-backed worker (`COMPUTE_MODE=worker`) +- ⚡ **Segment-based TTS Playback** + - Sentence-aware generation with cached audio segments, background preloading, and resumable playback across EPUB, PDF, TXT, MD, and DOCX - 🎯 **Multi-Provider TTS Support** - - [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): supports multi-voice combinations (for example `af_heart+af_bella`) - - [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI): lightweight, CPU-friendly self-hosted TTS - - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) - - **Custom OpenAI-compatible**: any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints - - **Cloud TTS providers**: - - [**Replicate**](https://replicate.com/explore): includes a built-in catalog and supports any Replicate model ID via `Other` - - [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models - - [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts` -- 📖 **Read Along Experience** - - Real-time highlighting for PDF/EPUB, with built-in ONNX Whisper word-level timestamps in local compute mode -- 🛜 **Document Storage** - - Documents are persisted in server blob/object storage for consistent access -- ⚡ **Segment-based TTS Playback** for reusable generation + preloading - - Stores segment audio in object storage for fast replay/resume + - Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint + - Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others) - 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation -- 🔐 **Auth Optional by Design** - - Run no-auth for local use, or enable auth with user isolation and claim flow -- 🗂️ **Flexible Storage and Database Modes** with embedded defaults or external S3/Postgres -- 🚀 **Production-ready Server Behavior** with TTS caching/retries/rate limits and startup migrations -- 🎨 **Customizable Experience** - - 13 built-in themes (light and dark palettes), TTS, and document handling controls +- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync +- 🔐 **Auth Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments +- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls ## 🧭 Key Docs diff --git a/tsconfig.json b/tsconfig.json index 00d1d04..19d4e79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,11 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "docs-site", "docs-site/**"] + "exclude": [ + "node_modules", + "docs-site", + "docs-site/**", + "compute/worker", + "compute/worker/**" + ] } From bab3416a36de4cdb53aa80d247a8890a0eedcd0e Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 15:55:35 -0600 Subject: [PATCH 011/137] refactor(compute): add timing metrics to worker job results and status Introduce WorkerJobTiming interface to capture queue wait, S3 fetch, and compute durations for compute worker jobs. Update job result and status response types to include timing data. Enhance worker implementation to record and attach timing metrics for alignment jobs, improving observability of job processing latency. Update exports and type usage to support new timing fields. --- compute/core/src/index.ts | 9 ++ compute/worker/src/server.ts | 136 +++++++++++++++++++--- compute/worker/tsconfig.json | 3 +- src/lib/server/compute/worker-contract.ts | 1 + 4 files changed, 130 insertions(+), 19 deletions(-) diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index 6f9ca15..24afe87 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -31,6 +31,7 @@ export interface WhisperAlignJobRequest { export interface WhisperAlignJobResult { alignments: TTSSentenceAlignment[]; + timing?: WorkerJobTiming; } export interface PdfLayoutJobRequest { @@ -41,6 +42,7 @@ export interface PdfLayoutJobRequest { export interface PdfLayoutJobResult { parsed: ParsedPdfDocument; + timing?: WorkerJobTiming; } export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; @@ -50,10 +52,17 @@ export interface WorkerJobErrorShape { code?: string; } +export interface WorkerJobTiming { + queueWaitMs?: number; + s3FetchMs?: number; + computeMs?: number; +} + export interface WorkerJobStatusResponse { status: WorkerJobState; result?: Result; error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; } export async function ensureComputeModels(): Promise { diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 8226dc3..ebbfcf8 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -13,6 +13,7 @@ import { type WhisperAlignJobRequest, type WhisperAlignJobResult, type WorkerJobStatusResponse, + type WorkerJobTiming, } from '@openreader/compute-core'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; @@ -152,23 +153,68 @@ const layoutSchema = z.object({ }); function mapJobState(job: Job): WorkerJobStatusResponse { + const result = job.returnvalue as Result | undefined; + const resultTiming = readResultTiming(result); + const timing = buildJobTimingSnapshot(job, resultTiming); + if (job.failedReason) { return { status: 'failed', error: { message: job.failedReason || 'Worker job failed', }, + ...(timing ? { timing } : {}), }; } if (typeof job.returnvalue !== 'undefined' && job.finishedOn) { return { status: 'succeeded', result: job.returnvalue as Result, + ...(timing ? { timing } : {}), }; } - if (job.processedOn) return { status: 'running' }; - return { status: 'queued' }; + if (job.processedOn) { + return { + status: 'running', + ...(timing ? { timing } : {}), + }; + } + return { + status: 'queued', + ...(timing ? { timing } : {}), + }; +} + +function readResultTiming(result: Result | undefined): WorkerJobTiming | undefined { + if (!result || typeof result !== 'object') return undefined; + const maybe = result as { timing?: WorkerJobTiming }; + if (!maybe.timing || typeof maybe.timing !== 'object') return undefined; + return maybe.timing; +} + +function toSafeDurationMs(start: number | undefined, end: number | undefined): number | undefined { + if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined; + return Math.max(0, Math.floor((end as number) - (start as number))); +} + +function buildJobTimingSnapshot(job: Job, base?: WorkerJobTiming): WorkerJobTiming | undefined { + const now = Date.now(); + const timestamp = typeof job.timestamp === 'number' ? job.timestamp : undefined; + const processedOn = typeof job.processedOn === 'number' ? job.processedOn : undefined; + + const timing: WorkerJobTiming = { + ...(base ?? {}), + }; + + if (typeof timing.queueWaitMs !== 'number') { + timing.queueWaitMs = processedOn + ? toSafeDurationMs(timestamp, processedOn) + : toSafeDurationMs(timestamp, now); + } + + const hasAnyTiming = Object.values(timing).some((value) => typeof value === 'number' && Number.isFinite(value)); + return hasAnyTiming ? timing : undefined; } async function getQueueDepth(queue: Queue): Promise { @@ -246,9 +292,16 @@ async function main(): Promise { const alignWorker = new Worker( ALIGN_QUEUE_NAME, async (job) => { + const processingStartedAt = Date.now(); + const queueWaitMs = typeof job.timestamp === 'number' + ? Math.max(0, processingStartedAt - job.timestamp) + : undefined; const parsed = alignSchema.parse(job.data); + const s3FetchStartedAt = Date.now(); const audioBuffer = await readObjectByKey(parsed.audioObjectKey); - return withTimeout( + const s3FetchMs = Date.now() - s3FetchStartedAt; + const computeStartedAt = Date.now(); + const result = await withTimeout( runWhisperAlignmentFromAudioBuffer({ audioBuffer, text: parsed.text, @@ -258,6 +311,16 @@ async function main(): Promise { whisperTimeoutMs, 'whisper alignment job', ); + const computeMs = Date.now() - computeStartedAt; + return { + ...result, + timing: { + ...(result.timing ?? {}), + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; }, { connection: redis, @@ -268,9 +331,16 @@ async function main(): Promise { const layoutWorker = new Worker( PDF_LAYOUT_QUEUE_NAME, async (job) => { + const processingStartedAt = Date.now(); + const queueWaitMs = typeof job.timestamp === 'number' + ? Math.max(0, processingStartedAt - job.timestamp) + : undefined; const parsed = layoutSchema.parse(job.data); + const s3FetchStartedAt = Date.now(); const pdfBytes = await readObjectByKey(parsed.documentObjectKey); - return withTimeout( + const s3FetchMs = Date.now() - s3FetchStartedAt; + const computeStartedAt = Date.now(); + const result = await withTimeout( runPdfLayoutFromPdfBuffer({ documentId: parsed.documentId, pdfBytes, @@ -278,6 +348,16 @@ async function main(): Promise { pdfTimeoutMs, 'pdf layout job', ); + const computeMs = Date.now() - computeStartedAt; + return { + ...result, + timing: { + ...(result.timing ?? {}), + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; }, { connection: redis, @@ -285,20 +365,6 @@ async function main(): Promise { }, ); - alignWorker.on('failed', (job, err) => { - console.error('[compute-worker] align job failed', { - jobId: job?.id, - error: err.message, - }); - }); - - layoutWorker.on('failed', (job, err) => { - console.error('[compute-worker] layout job failed', { - jobId: job?.id, - error: err.message, - }); - }); - if (prewarmModels) { await ensureComputeModels(); } @@ -307,6 +373,40 @@ async function main(): Promise { logger: buildLoggerConfig(), }); + alignWorker.on('completed', (job, result) => { + app.log.info({ + queue: ALIGN_QUEUE_NAME, + jobId: job.id, + timing: buildJobTimingSnapshot(job, readResultTiming(result)), + }, 'whisper align job completed'); + }); + + alignWorker.on('failed', (job, err) => { + app.log.error({ + queue: ALIGN_QUEUE_NAME, + jobId: job?.id, + error: err.message, + timing: job ? buildJobTimingSnapshot(job) : undefined, + }, 'whisper align job failed'); + }); + + layoutWorker.on('completed', (job, result) => { + app.log.info({ + queue: PDF_LAYOUT_QUEUE_NAME, + jobId: job.id, + timing: buildJobTimingSnapshot(job, readResultTiming(result)), + }, 'pdf layout job completed'); + }); + + layoutWorker.on('failed', (job, err) => { + app.log.error({ + queue: PDF_LAYOUT_QUEUE_NAME, + jobId: job?.id, + error: err.message, + timing: job ? buildJobTimingSnapshot(job) : undefined, + }, 'pdf layout job failed'); + }); + app.addHook('onRequest', async (request, reply) => { const path = request.url.split('?')[0] ?? request.url; if (path === '/health/live' || path === '/health/ready') return; diff --git a/compute/worker/tsconfig.json b/compute/worker/tsconfig.json index 3e68ae5..5912d1a 100644 --- a/compute/worker/tsconfig.json +++ b/compute/worker/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": [] } diff --git a/src/lib/server/compute/worker-contract.ts b/src/lib/server/compute/worker-contract.ts index ec4df20..b43cae9 100644 --- a/src/lib/server/compute/worker-contract.ts +++ b/src/lib/server/compute/worker-contract.ts @@ -7,5 +7,6 @@ export { type WhisperAlignJobResult, type WorkerJobErrorShape, type WorkerJobState, + type WorkerJobTiming, type WorkerJobStatusResponse, } from '@openreader/compute-core'; From f5d7408f179eb91ddca1c16fd0d5a446b9219741 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 18:25:27 -0600 Subject: [PATCH 012/137] refactor(pdf-layout): remove compute "none" mode and unsupported parse status Eliminate the "none" compute mode and all related code paths, including the NoneComputeBackend, "unsupported" parse status, and PDF margin settings. Parsing is now always available if the app starts successfully, and configuration is limited to "local" or "worker" compute modes. Update types, API routes, client adapters, and documentation to reflect this simplification. BREAKING CHANGE: "none" is no longer a valid COMPUTE_MODE; only "local" and "worker" are supported. "unsupported" parse status and PDF margin settings are removed. --- .env.example | 1 - docs-site/docs/deploy/vercel-deployment.md | 1 - .../docs/reference/environment-variables.md | 3 +- docs-site/docs/reference/stack.md | 2 +- src/app/(app)/pdf/[id]/page.tsx | 99 +++++- src/app/(app)/pdf/[id]/usePdfDocument.ts | 114 +------ src/app/api/documents/[id]/parsed/route.ts | 36 +- src/app/api/documents/route.ts | 9 +- src/components/documents/DocumentSettings.tsx | 308 +++++++----------- src/components/views/PDFViewer.tsx | 57 +++- src/lib/client/api/documents.ts | 4 +- src/lib/client/audiobooks/adapters/pdf.ts | 51 +-- src/lib/client/pdf.ts | 32 +- src/lib/server/compute/index.ts | 8 +- src/lib/server/compute/mode.ts | 16 +- src/lib/server/compute/none.ts | 17 - src/lib/server/compute/types.ts | 9 +- src/lib/server/jobs/parsePdfJob.ts | 3 +- src/lib/server/runtime-config.ts | 4 +- src/lib/shared/document-settings.ts | 15 - src/types/document-settings.ts | 1 - src/types/documents.ts | 2 +- src/types/parsed-pdf.ts | 2 +- tests/unit/pdf-audiobook-adapter.spec.ts | 1 - 24 files changed, 343 insertions(+), 452 deletions(-) delete mode 100644 src/lib/server/compute/none.ts diff --git a/.env.example b/.env.example index 74afbf0..7457a88 100644 --- a/.env.example +++ b/.env.example @@ -79,7 +79,6 @@ IMPORT_LIBRARY_DIRS= # Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. # local = run compute in-process (default) -# none = disable both capabilities (good for preview/serverless) # worker = external durable worker mode (requires Redis-backed compute-worker service) COMPUTE_MODE=local # Required when COMPUTE_MODE=worker diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index e9a1162..3c1502b 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -40,7 +40,6 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in # Heavy compute (recommended on Vercel in v1) # local = requires native binaries/models in-process (not recommended on Vercel) # worker = external durable compute worker (recommended) -# none = disable ONNX whisper alignment + PDF layout parsing COMPUTE_MODE=worker COMPUTE_WORKER_URL=https://your-compute-worker.example.com COMPUTE_WORKER_TOKEN=... diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 3053452..207b30a 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,7 +53,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable ONNX word alignment + PDF layout parsing | +| `COMPUTE_MODE` | Heavy compute backend | `local` | Select `local` (in-process) or `worker` (external worker service) | | `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required when `COMPUTE_MODE=worker`; base URL for external compute worker | | `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Required bearer token for external compute worker auth | | `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | @@ -358,7 +358,6 @@ Selects the backend for heavy compute features (ONNX word alignment + PDF layout - Supported in v1: - `local`: run compute in-process on the app server - `worker`: enqueue async jobs in an external durable compute worker (Redis + BullMQ) - - `none`: disable these features cleanly - `worker` requires `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` - `worker` assumes the external worker can directly reach shared object storage (S3-compatible endpoint) - `worker` is not compatible with non-exposed embedded `weed mini` storage topologies diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 7534a86..7f4061a 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -59,7 +59,7 @@ Monorepo packages under `compute/`: - Storage: AWS SDK v3 S3 client for reading/writing blobs - Logging: [Pino](https://getpino.io/) - Validation: [Zod](https://zod.dev/) -- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process), `worker` (remote queue via HTTP + Redis), or disabled +- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process) or `worker` (remote queue via HTTP + Redis) ## Tooling and testing diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index c11441b..ba23846 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -3,7 +3,7 @@ import dynamic from 'next/dynamic'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/documents/DocumentSettings'; @@ -19,6 +19,7 @@ import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { LoadingSpinner } from '@/components/Spinner'; import { usePdfDocument } from './usePdfDocument'; // Dynamic import for client-side rendering only @@ -32,7 +33,6 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); - const computeAvailable = useFeatureFlag('computeAvailable'); const { id } = useParams(); const router = useRouter(); const pdfState = usePdfDocument(); @@ -60,6 +60,11 @@ export default function PDFViewerPage() { const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); + const backNavTimeoutRef = useRef | null>(null); + const clearCurrDocRef = useRef(clearCurrDoc); + const [isNavigatingBack, setIsNavigatingBack] = useState(false); + const parseState = parseStatus ?? 'pending'; + const isParseReady = parseState === 'ready'; useEffect(() => { setIsLoading(true); @@ -115,6 +120,25 @@ export default function PDFViewerPage() { loadDocument(); }, [loadDocument]); + useEffect(() => { + clearCurrDocRef.current = clearCurrDoc; + }, [clearCurrDoc]); + + useEffect(() => { + return () => { + if (backNavTimeoutRef.current) { + clearTimeout(backNavTimeoutRef.current); + } + clearCurrDocRef.current(); + }; + }, []); + + useEffect(() => { + if (isLoading) return; + if (isParseReady) return; + stop(); + }, [isLoading, isParseReady, stop]); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -134,14 +158,33 @@ export default function PDFViewerPage() { const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); + const handleBackToDocuments = useCallback((event?: MouseEvent) => { + event?.preventDefault(); + if (isNavigatingBack) return; + setIsNavigatingBack(true); + stop(); + const hadOpenSidebar = activeSidebar !== null; + setActiveSidebar(null); + const delayMs = hadOpenSidebar ? 220 : 0; + if (backNavTimeoutRef.current) { + clearTimeout(backNavTimeoutRef.current); + } + backNavTimeoutRef.current = setTimeout(() => { + router.push('/app'); + }, delayMs); + }, [isNavigatingBack, stop, activeSidebar, router]); + const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, settings: AudiobookGenerationSettings ) => { + if (!isParseReady) { + throw new Error('PDF parsing is not ready yet.'); + } return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); - }, [createPDFAudioBook, id]); + }, [createPDFAudioBook, id, isParseReady]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, @@ -149,8 +192,11 @@ export default function PDFViewerPage() { settings: AudiobookGenerationSettings, signal: AbortSignal ) => { + if (!isParseReady) { + throw new Error('PDF parsing is not ready yet.'); + } return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); - }, [regeneratePDFChapter]); + }, [regeneratePDFChapter, isParseReady]); if (error) { return ( @@ -158,7 +204,7 @@ export default function PDFViewerPage() {

{error}

{ clearCurrDoc(); }} + onClick={handleBackToDocuments} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -170,13 +216,42 @@ export default function PDFViewerPage() { ); } + const renderPdfStatusLoader = () => { + let statusText = 'Loading PDF...'; + if (!isLoading) { + if (parseState === 'pending') { + statusText = 'Preparing PDF layout...'; + } else if (parseState === 'running') { + statusText = 'Parsing PDF layout blocks...'; + } else if (parseState === 'failed') { + statusText = 'PDF parsing failed. Retry to continue.'; + } + } + + return ( +
+ +

{statusText}

+ {!isLoading && parseState === 'failed' ? ( + + ) : null} +
+ ); + }; + return ( <>
clearCurrDoc()} + onClick={handleBackToDocuments} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" > @@ -207,11 +282,8 @@ export default function PDFViewerPage() { } />
- - {isLoading ? ( -
- -
+ {isLoading || !isParseReady ? ( + renderPdfStatusLoader() ) : ( )} @@ -233,14 +305,13 @@ export default function PDFViewerPage() {
- ) : ( + ) : isParseReady ? ( - )} + ) : null} setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} pdf={{ - computeAvailable, parseStatus, parsedOverlayEnabled, skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 774f46d..6cb221e 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -31,12 +31,12 @@ import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { - extractTextFromPDF, highlightPattern, clearHighlights, clearWordHighlights, highlightWordIndex, } from '@/lib/client/pdf'; +import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, @@ -113,9 +113,6 @@ export interface PdfDocumentState { isAudioCombining: boolean; } -const EMPTY_TEXT_RETRY_DELAY_MS = 120; -const EMPTY_TEXT_MAX_RETRIES = 6; - /** * Main PDF route hook. */ @@ -130,10 +127,6 @@ export function usePdfDocument(): PdfDocumentState { registerVisualPageChangeHandler, } = useTTS(); const { - headerMargin, - footerMargin, - leftMargin, - rightMargin, apiKey, baseUrl, providerRef, @@ -154,18 +147,11 @@ export function usePdfDocument(): PdfDocumentState { const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ - pdfDocument, parsed: parsedDocument ?? undefined, settings: documentSettings, - margins: { - header: documentSettings.pdf?.margins?.header ?? headerMargin, - footer: documentSettings.pdf?.margins?.footer ?? footerMargin, - left: documentSettings.pdf?.margins?.left ?? leftMargin, - right: documentSettings.pdf?.margins?.right ?? rightMargin, - }, smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [pdfDocument, parsedDocument, documentSettings, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [pdfDocument, parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -174,7 +160,6 @@ export function usePdfDocument(): PdfDocumentState { const pdfDocGenerationRef = useRef(0); const pdfDocumentRef = useRef(undefined); const loadSeqRef = useRef(0); - const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. const docLoadSeqRef = useRef(0); @@ -190,10 +175,6 @@ export function usePdfDocument(): PdfDocumentState { // document backfills parse output via the parsed endpoint polling path. const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending'; setParseStatus(effectiveInitialStatus); - if (effectiveInitialStatus === 'unsupported') { - setParsedDocument(null); - return; - } const maxAttempts = 25; const delayMs = 1200; @@ -210,7 +191,7 @@ export function usePdfDocument(): PdfDocumentState { return; } setParseStatus(result.status); - if (result.status === 'failed' || result.status === 'unsupported') { + if (result.status === 'failed') { setParsedDocument(null); return; } @@ -262,7 +243,7 @@ export function usePdfDocument(): PdfDocumentState { /** * Loads and processes text from the current document page - * Extracts text from the PDF and updates both document text and TTS text states + * Uses parsed PDF blocks only and updates both document text and TTS text states. * * @returns {Promise} */ @@ -274,30 +255,14 @@ export function usePdfDocument(): PdfDocumentState { const seq = ++loadSeqRef.current; const pageNumber = currDocPageNumber; - const existingRetry = emptyRetryRef.current; - if (existingRetry?.timer) { - clearTimeout(existingRetry.timer); - } - emptyRetryRef.current = - existingRetry && existingRetry.page === pageNumber - ? { ...existingRetry, timer: null } - : null; - - const margins = { - header: documentSettings.pdf?.margins?.header ?? headerMargin, - footer: documentSettings.pdf?.margins?.footer ?? footerMargin, - left: documentSettings.pdf?.margins?.left ?? leftMargin, - right: documentSettings.pdf?.margins?.right ?? rightMargin - }; - const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => parsedDocument?.pages.find((page) => page.pageNumber === pageNum); - const hasUsableParsedBlocks = (page: ParsedPdfPage | undefined): page is ParsedPdfPage => { - if (!page) return false; - const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); - return page.blocks.some((block) => !skipKinds.has(block.kind) && block.text.trim().length > 0); - }; + if (parseStatus !== 'ready' || !parsedDocument) { + setCurrDocText(undefined); + setTTSText('', { location: currDocPageNumber }); + return; + } const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { const page = pageFromParsed(pageNum); @@ -332,14 +297,9 @@ export function usePdfDocument(): PdfDocumentState { } const parsedPage = pageFromParsed(pageNumber); - const useParsedPage = hasUsableParsedBlocks(parsedPage) ? parsedPage : undefined; - const extracted = await extractTextFromPDF( - currentPdf, - pageNumber, - margins, - useParsedPage, - useParsedPage ? documentSettings.pdf?.skipBlockKinds : undefined, - ); + const extracted = parsedPage + ? buildPageTextFromBlocks(parsedPage, documentSettings.pdf?.skipBlockKinds ?? []) + : ''; if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); @@ -383,44 +343,15 @@ export function usePdfDocument(): PdfDocumentState { return; } - const trimmed = text.trim(); - if (!trimmed) { - const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0; - const attempt = prevAttempt + 1; - - // Avoid pushing empty text into TTS immediately; transient empty extractions can happen - // during page turns or react-pdf worker churn. Retry a few times before treating it as - // a truly blank page. - if (attempt <= EMPTY_TEXT_MAX_RETRIES) { - const timer = setTimeout(() => { - if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { - return; - } - if (pageNumber !== currDocPageNumber) { - return; - } - void loadCurrDocText(); - }, EMPTY_TEXT_RETRY_DELAY_MS); - - emptyRetryRef.current = { page: pageNumber, attempt, timer }; - return; - } - } else { - emptyRetryRef.current = null; - } - if (text !== currDocText || text === '') { setCurrDocText(text); const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); - const useBlockStructuredPlanning = sourceUnits.length > 0; setTTSText(text, { location: currDocPageNumber, - ...(!useBlockStructuredPlanning ? { - previousText: prevText, - nextLocation: nextPageNumber, - nextText: nextText, - upcomingLocations: additionalUpcoming, - } : {}), + previousText: prevText, + nextLocation: nextPageNumber, + nextText: nextText, + upcomingLocations: additionalUpcoming, ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } @@ -435,12 +366,9 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, setTTSText, currDocText, - headerMargin, - footerMargin, - leftMargin, - rightMargin, segmentPreloadDepthPages, parsedDocument, + parseStatus, documentSettings, ]); @@ -474,10 +402,6 @@ export function usePdfDocument(): PdfDocumentState { // or fast refresh. pdfDocGenerationRef.current += 1; loadSeqRef.current += 1; - if (emptyRetryRef.current?.timer) { - clearTimeout(emptyRetryRef.current.timer); - } - emptyRetryRef.current = null; parsePollAbortRef.current?.abort(); parsePollAbortRef.current = null; pageTextCacheRef.current.clear(); @@ -570,10 +494,6 @@ export function usePdfDocument(): PdfDocumentState { docLoadAbortRef.current = null; parsePollAbortRef.current?.abort(); parsePollAbortRef.current = null; - if (emptyRetryRef.current?.timer) { - clearTimeout(emptyRetryRef.current.timer); - } - emptyRetryRef.current = null; setCurrDocId(undefined); setCurrDocName(undefined); setCurrDocData(undefined); diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 96e281c..d23bfa2 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -23,6 +23,13 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); } +function normalizeParseStatus( + status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null, +): 'pending' | 'running' | 'ready' | 'failed' { + if (status === 'unsupported' || status === null) return 'pending'; + return status; +} + export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -60,7 +67,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - if (row.parseStatus === 'failed' && retryFailed) { + if (row.parseStatus === 'unsupported') { await db .update(documents) .set({ parseStatus: 'pending' }) @@ -73,15 +80,30 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); } - if (row.parseStatus !== 'ready') { - if (row.parseStatus === 'pending' || row.parseStatus === 'running' || row.parseStatus === null) { + const effectiveStatus = normalizeParseStatus(row.parseStatus); + + if (effectiveStatus === 'failed' && retryFailed) { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + } + + if (effectiveStatus !== 'ready') { + if (effectiveStatus === 'pending' || effectiveStatus === 'running') { enqueueParsePdfJob({ documentId: id, userId: row.userId, namespace: testNamespace, }); } - return NextResponse.json({ parseStatus: row.parseStatus ?? 'pending' }, { status: 202 }); + return NextResponse.json({ parseStatus: effectiveStatus }, { status: 202 }); } try { @@ -161,7 +183,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - if (row.parseStatus !== 'running') { + const effectiveStatus = normalizeParseStatus(row.parseStatus); + + if (effectiveStatus !== 'running') { await db .update(documents) .set({ parseStatus: 'pending' }) @@ -175,7 +199,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }); return NextResponse.json( - { parseStatus: row.parseStatus === 'running' ? 'running' : 'pending' }, + { parseStatus: effectiveStatus === 'running' ? 'running' : 'pending' }, { status: 202 }, ); } catch (error) { diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index af1e835..966604c 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -25,6 +25,13 @@ type RegisterDocument = { lastModified: number; }; +function normalizeParseStatus( + status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null, +): 'pending' | 'running' | 'ready' | 'failed' | null { + if (status === 'unsupported') return 'pending'; + return status; +} + function s3NotConfiguredResponse(): NextResponse { return NextResponse.json( { error: 'Documents storage is not configured. Set S3_* environment variables.' }, @@ -245,7 +252,7 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, - parseStatus: doc.parseStatus, + parseStatus: normalizeParseStatus(doc.parseStatus), parsedJsonKey: doc.parsedJsonKey, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index ed6aa92..9a5ec8a 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, type ChangeEvent } from 'react'; +import { useState, useEffect } from 'react'; import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { @@ -15,7 +15,6 @@ import { clampSegmentPreloadSentenceLookahead, clampTtsSegmentMaxBlockLength, } from '@/types/config'; -import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; @@ -52,8 +51,6 @@ const viewTypeTextMapping = [ const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent'; -type MarginKey = 'header' | 'footer' | 'left' | 'right'; - type RangeSettingProps = { label: string; value: number; @@ -103,7 +100,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { epub?: boolean, html?: boolean, pdf?: { - computeAvailable: boolean; parseStatus: PdfParseStatus | null; parsedOverlayEnabled: boolean; skipBlockKinds: ParsedPdfBlockKind[]; @@ -114,8 +110,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { onForceReparse: () => void; } }) { - const computeAvailable = useFeatureFlag('computeAvailable'); - const canWordHighlight = computeAvailable; + const canWordHighlight = true; const { viewType, skipBlank, @@ -124,10 +119,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, - headerMargin, - footerMargin, - leftMargin, - rightMargin, updateConfigKey, pdfHighlightEnabled, epubHighlightEnabled, @@ -136,31 +127,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { htmlHighlightEnabled, htmlWordHighlightEnabled, } = useConfig(); - const [localMargins, setLocalMargins] = useState({ - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin - }); const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0]; + const isPdfMode = !epub && !html && !!pdf; const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages); const [localSentenceLookahead, setLocalSentenceLookahead] = useState(segmentPreloadSentenceLookahead); const [localMaxBlockLength, setLocalMaxBlockLength] = useState(ttsSegmentMaxBlockLength); - const marginValues: Record = { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin, - }; - - useEffect(() => { - setLocalMargins({ - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin - }); - }, [headerMargin, footerMargin, leftMargin, rightMargin]); useEffect(() => { setLocalPreloadDepth(segmentPreloadDepthPages); @@ -174,22 +145,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { setLocalMaxBlockLength(ttsSegmentMaxBlockLength); }, [ttsSegmentMaxBlockLength]); - // Handler for slider release - const handleMarginChangeComplete = (margin: MarginKey) => () => { - const value = localMargins[margin]; - const configKey = `${margin}Margin`; - if (value !== marginValues[margin]) { - updateConfigKey(configKey as 'headerMargin' | 'footerMargin' | 'leftMargin' | 'rightMargin', value); - } - }; - - const handleMarginSliderChange = (margin: MarginKey) => (event: ChangeEvent) => { - setLocalMargins((previous) => ({ - ...previous, - [margin]: Number(event.target.value), - })); - }; - return (
+ {isPdfMode && pdf && ( +
+
+ +
+ {viewTypeTextMapping.map((view) => { + const active = selectedView.id === view.id; + return ( + + ); + })} +
+ {selectedView.id === 'scroll' ? ( +

Scroll mode may be slower on large PDFs.

+ ) : null} +
+ updateConfigKey('pdfHighlightEnabled', checked)} + variant="flat" + /> + updateConfigKey('pdfWordHighlightEnabled', checked)} + variant="flat" + /> + +
+ )} + + {isPdfMode && pdf && ( +
+ + + PP-DocLayout-V3 + + + {pdf.parseStatus ?? 'pending'} + + +
+ } + > + +
+ + Skip Block Kinds While Reading Aloud + +
+ {PDF_SKIP_KIND_OPTIONS.map((option) => ( + pdf.onToggleSkipKind(option.kind, enabled)} + /> + ))} +
+
+ + )} +
- {!epub && !html && ( -
-
- -
- {viewTypeTextMapping.map((view) => { - const active = selectedView.id === view.id; - return ( - - ); - })} -
- {selectedView.id === 'scroll' ? ( -

Scroll mode may be slower on large PDFs.

- ) : null} -
- -
-

Text extraction margins

-

- Ignore edge content before extraction. -

-
- {(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => ( -
-
- {margin} - {Math.round(localMargins[margin] * 100)}% -
- -
- ))} -
-
-
- )} - - {!epub && !html && ( -
- updateConfigKey('pdfHighlightEnabled', checked)} - variant="flat" - /> - updateConfigKey('pdfWordHighlightEnabled', checked)} - variant="flat" - /> -
- )} - {epub && (
)} - - {!epub && !html && pdf && ( -
- - - PP-DocLayout-V3 - - - {pdf.parseStatus ?? 'pending'} - - - - } - > - - -
-

Skip while reading aloud

-
- {PDF_SKIP_KIND_OPTIONS.map((option) => ( - pdf.onToggleSkipKind(option.kind, enabled)} - /> - ))} -
-
-
- )}
); diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 48c961b..8bbecec 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -119,6 +119,13 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { wordHighlightTimeoutsRef.current.push(t); }, []); + useEffect(() => { + return () => { + clearHighlights(); + clearWordHighlights(); + }; + }, [clearHighlights, clearWordHighlights]); + useEffect(() => { /* * Handles highlighting the current sentence being read by TTS. @@ -146,6 +153,12 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return; } + // Root-cause guard: do not repaint highlights while react-pdf is still + // replacing page/text layers for a new page or viewport layout. + if (isPageRendering) { + return; + } + const seq = ++sentenceHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current; lastSentenceLayoutKeyRef.current = layoutKey; @@ -156,38 +169,40 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { && typeof activeLocator.blockId === 'string' && activeLocator.blockId.length > 0; - if (isLayoutChange) { + if (isLayoutChange || !hasParsedBlockLocator) { clearHighlights(); } + if (!hasParsedBlockLocator) { + return; + } + + const useBlockGeometryOnly = !pdfWordHighlightEnabled; + const tryApply = (attempt: number) => { if (seq !== sentenceHighlightSeqRef.current) return; const container = containerRef.current; if (!container) return; - if (hasParsedBlockLocator) { - highlightPattern(currDocText, currentSentence, containerRef as RefObject, { - parsedDocument, - locator: activeLocator, - useBlockGeometryOnly: !pdfWordHighlightEnabled, - }); - return; + if (!useBlockGeometryOnly) { + const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); + if (!spans.length) { + if (attempt < 1) scheduleSentenceTimeout(() => tryApply(attempt + 1), 90); + return; + } } - const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); - if (!spans.length) { - if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75); - return; - } - - highlightPattern(currDocText, currentSentence, containerRef as RefObject); + highlightPattern(currDocText, currentSentence, containerRef as RefObject, { + parsedDocument, + locator: activeLocator, + useBlockGeometryOnly, + }); }; - scheduleSentenceTimeout(() => tryApply(0), 200); + scheduleSentenceTimeout(() => tryApply(0), useBlockGeometryOnly ? 80 : 120); return () => { clearSentenceHighlightTimeouts(); - clearHighlights(); }; }, [ currDocText, @@ -199,6 +214,7 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { pdfWordHighlightEnabled, parsedDocument, layoutKey, + isPageRendering, clearSentenceHighlightTimeouts, scheduleSentenceTimeout ]); @@ -228,6 +244,10 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return; } + if (isPageRendering) { + return; + } + const seq = ++wordHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastWordLayoutKeyRef.current; lastWordLayoutKeyRef.current = layoutKey; @@ -276,7 +296,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { highlightWordIndex, layoutKey, clearWordHighlightTimeouts, - scheduleWordTimeout + scheduleWordTimeout, + isPageRendering ]); // Add page dimensions state diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 829a392..882ad4c 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -81,7 +81,7 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort export async function getParsedPdfDocument( id: string, options?: { signal?: AbortSignal; retryFailed?: boolean }, -): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' | 'unsupported' }> { +): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' }> { const query = options?.retryFailed ? '?retry=1' : ''; const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { signal: options?.signal, @@ -91,7 +91,7 @@ export async function getParsedPdfDocument( if (res.status === 202) { const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; const parseStatus = data?.parseStatus; - if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed' || parseStatus === 'unsupported') { + if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed') { return { status: parseStatus }; } return { status: 'pending' }; diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 6c38631..9aff5ac 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -1,5 +1,3 @@ -import type { PDFDocumentProxy } from 'pdfjs-dist'; - import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline'; import { normalizeTextForTts } from '@/lib/shared/nlp'; import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; @@ -7,15 +5,8 @@ import type { DocumentSettings } from '@/types/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { - pdfDocument?: PDFDocumentProxy; parsed?: ParsedPdfDocument; settings?: DocumentSettings; - margins: { - header: number; - footer: number; - left: number; - right: number; - }; smartSentenceSplitting: boolean; maxBlockLength?: number; } @@ -90,47 +81,21 @@ function prepareParsedChapters({ } async function extractPreparedPdfChapters({ - pdfDocument, parsed, settings = DEFAULT_DOCUMENT_SETTINGS, - margins, smartSentenceSplitting, maxBlockLength, }: PdfAudiobookAdapterOptions): Promise { - if (parsed) { - const parsedChapters = prepareParsedChapters({ - parsed, - settings, - smartSentenceSplitting, - maxBlockLength, - }); - if (parsedChapters.length > 0) { - return parsedChapters; - } + if (!parsed) { + throw new Error('PDF parsing is not ready yet.'); } - if (!pdfDocument) { - throw new Error('No PDF document loaded'); - } - - const { extractTextFromPDF } = await import('@/lib/client/pdf'); - - const chapters: PreparedAudiobookChapter[] = []; - for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { - const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins); - const trimmedText = rawText.trim(); - if (!trimmedText) { - continue; - } - - chapters.push({ - index: chapters.length, - title: `Page ${chapters.length + 1}`, - text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText, - }); - } - - return chapters; + return prepareParsedChapters({ + parsed, + settings, + smartSentenceSplitting, + maxBlockLength, + }); } export function createPdfAudiobookSourceAdapter(options: PdfAudiobookAdapterOptions): AudiobookSourceAdapter { diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index 5941e8d..e1719dc 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -559,23 +559,20 @@ export function highlightPattern( const parsedDocument = options?.parsedDocument ?? null; const locator = options?.locator ?? null; - if ( - parsedDocument - && locator - && options?.useBlockGeometryOnly - && highlightParsedBlockGeometry(containerRef, parsedDocument, locator) - ) { + // Canonical path: parsed block locator is required for PDF sentence + // highlighting. Avoid broad full-page text matching fallbacks. + if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { return; } - const spanNodes = ( - parsedDocument && locator - ? (collectSpanNodesForParsedBlock(container, parsedDocument, locator) - ?? Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[]) - : Array.from(container.querySelectorAll('.react-pdf__Page__textContent span')) as HTMLElement[] - ); + const spanNodes = collectSpanNodesForParsedBlock(container, parsedDocument, locator) ?? []; - if (!spanNodes.length) return; + if (!spanNodes.length) { + if (options?.useBlockGeometryOnly) { + highlightParsedBlockGeometry(containerRef, parsedDocument, locator); + } + return; + } lastSpanNodes = spanNodes; const tokens: PDFToken[] = []; @@ -604,6 +601,15 @@ export function highlightPattern( if (!tokens.length) return; lastTokens = tokens; + if (options?.useBlockGeometryOnly) { + lastSentenceTokenWindow = { + start: 0, + end: tokens.length - 1, + }; + highlightParsedBlockGeometry(containerRef, parsedDocument, locator); + return; + } + const patternLen = cleanPattern.length; // Core application of highlight logic once we know the best token window (if any) diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index d9f847d..4424f1f 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -1,5 +1,4 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; -import { NoneComputeBackend } from '@/lib/server/compute/none'; import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; import { WorkerComputeBackend } from '@/lib/server/compute/worker'; @@ -7,10 +6,9 @@ let backend: ComputeBackend | null = null; function createBackend(): ComputeBackend { const mode: ComputeMode = readComputeMode(); - if (mode === 'none') return new NoneComputeBackend(); if (mode === 'worker') return new WorkerComputeBackend(); - // Intentionally lazy-load local compute so COMPUTE_MODE=none builds - // can avoid tracing heavy ONNX dependencies. + // Intentionally lazy-load local compute to avoid tracing heavy ONNX + // dependencies unless the backend is actually local. // eslint-disable-next-line @typescript-eslint/no-require-imports const { LocalComputeBackend } = require('@/lib/server/compute/local') as typeof import('@/lib/server/compute/local'); return new LocalComputeBackend(); @@ -22,5 +20,5 @@ export function getCompute(): ComputeBackend { } export function isComputeAvailable(): boolean { - return isComputeModeAvailable(readComputeMode()); + return isComputeModeAvailable(); } diff --git a/src/lib/server/compute/mode.ts b/src/lib/server/compute/mode.ts index 42b066e..5c2b83d 100644 --- a/src/lib/server/compute/mode.ts +++ b/src/lib/server/compute/mode.ts @@ -1,11 +1,17 @@ import type { ComputeMode } from '@/lib/server/compute/types'; export function readComputeMode(): ComputeMode { - const raw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); - if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; - return 'local'; + const envValue = process.env.COMPUTE_MODE; + const raw = (envValue || '').trim().toLowerCase(); + if (!raw) return 'local'; + if (raw === 'local' || raw === 'worker') return raw; + throw new Error( + `Invalid COMPUTE_MODE="${envValue}". Expected "local" or "worker".`, + ); } -export function isComputeModeAvailable(mode: ComputeMode): boolean { - return mode !== 'none'; +export function isComputeModeAvailable(): boolean { + // Throws on invalid values so startup/runtime config fails fast. + readComputeMode(); + return true; } diff --git a/src/lib/server/compute/none.ts b/src/lib/server/compute/none.ts deleted file mode 100644 index 64202e8..0000000 --- a/src/lib/server/compute/none.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; -import { UnsupportedComputeError } from '@/lib/server/compute/types'; -import type { ParsedPdfDocument } from '@/types/parsed-pdf'; - -export class NoneComputeBackend implements ComputeBackend { - readonly mode = 'none' as const; - - async alignWords(input: WhisperAlignInput): Promise { - void input; - throw new UnsupportedComputeError('Word alignment is unavailable: COMPUTE_MODE=none'); - } - - async parsePdfLayout(input: PdfLayoutInput): Promise { - void input; - throw new UnsupportedComputeError('PDF layout parsing is unavailable: COMPUTE_MODE=none'); - } -} diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index 2acea80..f316756 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -1,6 +1,6 @@ import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core'; -export type ComputeMode = 'local' | 'worker' | 'none'; +export type ComputeMode = 'local' | 'worker'; export interface WhisperAlignInput { audioBuffer?: TTSAudioBuffer; @@ -26,10 +26,3 @@ export interface ComputeBackend { alignWords(input: WhisperAlignInput): Promise; parsePdfLayout(input: PdfLayoutInput): Promise; } - -export class UnsupportedComputeError extends Error { - constructor(message: string) { - super(message); - this.name = 'UnsupportedComputeError'; - } -} diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index fba48e4..1410949 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -1,7 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { UnsupportedComputeError } from '@/lib/server/compute/types'; import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; @@ -58,7 +57,7 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; const cause = error instanceof Error ? error.cause : undefined; - const parseStatus = error instanceof UnsupportedComputeError ? 'unsupported' : 'failed'; + const parseStatus = 'failed'; try { await db .update(documents) diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index e1585c3..14be70f 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,7 +6,7 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; -import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; +import { isComputeModeAvailable } from '@/lib/server/compute/mode'; export type ResolvedRuntimeConfig = RuntimeConfig & { computeAvailable: boolean; @@ -29,7 +29,7 @@ export async function getResolvedRuntimeConfig(): Promise const values = await getRuntimeConfig(); return { ...values, - computeAvailable: isComputeModeAvailable(readComputeMode()), + computeAvailable: isComputeModeAvailable(), }; } diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts index 537038a..323a039 100644 --- a/src/lib/shared/document-settings.ts +++ b/src/lib/shared/document-settings.ts @@ -16,19 +16,6 @@ function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { return Array.from(new Set(out)); } -type PdfMargins = { header: number; footer: number; left: number; right: number }; - -function normalizeMargins(value: unknown): PdfMargins | undefined { - if (!value || typeof value !== 'object') return undefined; - const rec = value as Record; - const header = Number(rec.header); - const footer = Number(rec.footer); - const left = Number(rec.left); - const right = Number(rec.right); - if (![header, footer, left, right].every((n) => Number.isFinite(n))) return undefined; - return { header, footer, left, right }; -} - export function mergeDocumentSettings( defaults: DocumentSettings = DEFAULT_DOCUMENT_SETTINGS, stored: unknown, @@ -38,7 +25,6 @@ export function mergeDocumentSettings( pdf: { skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true, - ...(defaults.pdf?.margins ? { margins: defaults.pdf.margins } : {}), }, }; @@ -56,7 +42,6 @@ export function mergeDocumentSettings( typeof pdfRec.chaptersFromSections === 'boolean' ? pdfRec.chaptersFromSections : (base.pdf?.chaptersFromSections ?? true), - ...(normalizeMargins(pdfRec.margins) ? { margins: normalizeMargins(pdfRec.margins) } : {}), }, }; } diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts index 0262ed5..1bfd1ba 100644 --- a/src/types/document-settings.ts +++ b/src/types/document-settings.ts @@ -4,7 +4,6 @@ export interface DocumentSettings { schemaVersion: 1; pdf?: { skipBlockKinds: ParsedPdfBlockKind[]; - margins?: { header: number; footer: number; left: number; right: number }; chaptersFromSections: boolean; }; } diff --git a/src/types/documents.ts b/src/types/documents.ts index b25d8fe..3fa90c7 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,7 +6,7 @@ export interface BaseDocument { size: number; lastModified: number; type: DocumentType; - parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; parsedJsonKey?: string | null; scope?: 'user' | 'unclaimed'; folderId?: string; diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts index c1c1daa..2ee67af 100644 --- a/src/types/parsed-pdf.ts +++ b/src/types/parsed-pdf.ts @@ -76,4 +76,4 @@ export interface ParsedPdfDocument { pages: ParsedPdfPage[]; } -export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed' | 'unsupported'; +export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed'; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index cf357cf..c9d9bd8 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -62,7 +62,6 @@ test.describe('pdf audiobook adapter', () => { const adapter = createPdfAudiobookSourceAdapter({ parsed, settings, - margins: { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, smartSentenceSplitting: false, }); From e52b7541064e12617f0745697bebf5fb7f60175d Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 19:11:51 -0600 Subject: [PATCH 013/137] refactor(pdf): remove legacy text extraction and update parse status normalization Eliminate unused PDF text extraction logic and margin-based filtering from the client library. Refactor API routes to generalize parse status normalization, removing explicit references to deprecated "unsupported" status and type variants. Update usages to accept string parse statuses for improved flexibility. Simplify document state dependency tracking in the PDF document hook. --- src/app/(app)/pdf/[id]/usePdfDocument.ts | 2 +- src/app/api/documents/[id]/parsed/route.ts | 17 +-- src/app/api/documents/route.ts | 11 +- src/lib/client/pdf.ts | 136 +-------------------- 4 files changed, 19 insertions(+), 147 deletions(-) diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 6cb221e..90fe3ec 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -151,7 +151,7 @@ export function usePdfDocument(): PdfDocumentState { settings: documentSettings, smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [pdfDocument, parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index d23bfa2..b2ad9c4 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -24,10 +24,12 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { } function normalizeParseStatus( - status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null, + status: string | null, ): 'pending' | 'running' | 'ready' | 'failed' { - if (status === 'unsupported' || status === null) return 'pending'; - return status; + if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { + return status; + } + return 'pending'; } export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { @@ -59,7 +61,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; - parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parseStatus: string | null; }>; const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; @@ -67,7 +69,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - if (row.parseStatus === 'unsupported') { + const effectiveStatus = normalizeParseStatus(row.parseStatus); + if (row.parseStatus !== effectiveStatus) { await db .update(documents) .set({ parseStatus: 'pending' }) @@ -80,8 +83,6 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); } - const effectiveStatus = normalizeParseStatus(row.parseStatus); - if (effectiveStatus === 'failed' && retryFailed) { await db .update(documents) @@ -175,7 +176,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; - parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parseStatus: string | null; }>; const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 966604c..0d6a7b1 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -26,10 +26,13 @@ type RegisterDocument = { }; function normalizeParseStatus( - status: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null, + status: string | null, ): 'pending' | 'running' | 'ready' | 'failed' | null { - if (status === 'unsupported') return 'pending'; - return status; + if (status === null) return null; + if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { + return status; + } + return 'pending'; } function s3NotConfiguredResponse(): NextResponse { @@ -226,7 +229,7 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; - parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parseStatus: string | null; parsedJsonKey: string | null; }>; diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index e1719dc..9727d4b 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -1,10 +1,8 @@ import { pdfjs } from 'react-pdf'; -import type { TextItem } from 'pdfjs-dist/types/src/display/api'; -import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist'; +import { TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; -import type { ParsedPdfDocument, ParsedPdfPage, ParsedPdfBlockKind } from '@/types/parsed-pdf'; -import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; +import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf'; import { CmpStr } from 'cmpstr'; import type { TTSSegmentLocator } from '@/types/client'; @@ -188,136 +186,6 @@ const normalizeWordForMatch = (text: string): string => .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '') .toLowerCase(); -// Text Processing functions -export async function extractTextFromPDF( - pdf: PDFDocumentProxy, - pageNumber: number, - margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, - parsed?: ParsedPdfPage, - skipKinds?: ParsedPdfBlockKind[], -): Promise { - try { - if (parsed) { - return buildPageTextFromBlocks(parsed, skipKinds ?? []); - } - - const page = await pdf.getPage(pageNumber); - const textContent = await page.getTextContent(); - - const viewport = page.getViewport({ scale: 1.0 }); - const pageHeight = viewport.height; - const pageWidth = viewport.width; - - const textItems = textContent.items.filter((item): item is TextItem => { - if (!('str' in item && 'transform' in item)) return false; - - const [scaleX, skewX, skewY, scaleY, x, y] = item.transform; - - // Basic text filtering - if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false; - if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false; - if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; - - // Calculate margins in PDF coordinate space (y=0 is at bottom) - const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y - const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based - const leftX = pageWidth * margins.left; - const rightX = pageWidth * (1 - margins.right); - - // Check margins - remember y=0 is at bottom of page in PDF coordinates - if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area - return false; - } - - // Check horizontal margins - if (x < leftX || x > rightX) { - return false; - } - - // Sanity check for coordinates - if (x < 0 || x > pageWidth) return false; - - return item.str.trim().length > 0; - }); - - const tolerance = 2; - const lines: TextItem[][] = []; - let currentLine: TextItem[] = []; - let currentY: number | null = null; - - textItems.forEach((item) => { - const y = item.transform[5]; - if (currentY === null) { - currentY = y; - currentLine.push(item); - } else if (Math.abs(y - currentY) < tolerance) { - currentLine.push(item); - } else { - lines.push(currentLine); - currentLine = [item]; - currentY = y; - } - }); - lines.push(currentLine); - - let pageText = ''; - for (const line of lines) { - line.sort((a, b) => a.transform[4] - b.transform[4]); - let lineText = ''; - let prevItem: TextItem | null = null; - - for (const item of line) { - if (!prevItem) { - lineText = item.str; - } else { - const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); - const currentStartX = item.transform[4]; - const space = currentStartX - prevEndX; - - // Get average character width as fallback - const avgCharWidth = (item.width ?? 0) / Math.max(1, item.str.length); - - // Multiple conditions for space detection - const needsSpace = - // Primary check: significant gap between items - space > Math.max(avgCharWidth * 0.3, 2) || - // Secondary check: natural word boundary - (!/^\W/.test(item.str) && !/\W$/.test(prevItem.str)) || - // Tertiary check: items are far enough apart relative to their size - (space > ((prevItem.width ?? 0) * 0.25)); - - if (needsSpace) { - lineText += ' ' + item.str; - } else { - lineText += item.str; - } - } - prevItem = item; - } - pageText += lineText + ' '; - } - - return pageText.replace(/\s+/g, ' ').trim(); - } catch (error) { - // During Next.js fast refresh / route transitions, react-pdf can tear down the - // underlying worker and pdf.js may throw a TypeError like: - // "null is not an object (evaluating 'this.messageHandler.sendWithPromise')". - // Treat this as a cancellation so the app can ignore it. - if ( - error instanceof TypeError && - typeof error.message === 'string' && - error.message.includes('messageHandler') && - error.message.includes('sendWithPromise') - ) { - throw new DOMException('PDF worker torn down', 'AbortError'); - } - - console.error('Error extracting text from PDF:', error); - // Preserve the original error so callers can decide whether to retry/ignore. - throw error; - } -} - // Highlighting functions let highlightPatternSeq = 0; From 01cb95d8e7cc765964269ae8596bbb20c62d4cb3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 19:38:36 -0600 Subject: [PATCH 014/137] fix(compute): migrate compute backend initialization to async and update consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor compute backend initialization to use asynchronous loading and promise caching, replacing previous synchronous singleton pattern. Update all consumers—including TTS segment alignment and PDF layout parsing jobs—to await compute backend resolution before invoking methods. This change improves compatibility with dynamic imports and future-proofs backend selection logic. --- src/app/api/tts/segments/ensure/route.ts | 11 +++++++++-- src/lib/server/compute/index.ts | 20 +++++++++++--------- src/lib/server/jobs/parsePdfJob.ts | 3 ++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index aa80c1e..e16c084 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -150,6 +150,11 @@ async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Prom export async function POST(request: NextRequest) { let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; + let computeBackendPromise: ReturnType | null = null; + const getComputeBackend = async () => { + if (!computeBackendPromise) computeBackendPromise = getCompute(); + return computeBackendPromise; + }; try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -372,7 +377,8 @@ export async function POST(request: NextRequest) { // previously unavailable, retry alignment using the current segment text. if (!alignment) { try { - const aligned = (await getCompute().alignWords({ + const computeBackend = await getComputeBackend(); + const aligned = (await computeBackend.alignWords({ audioObjectKey: existing.audioKey, text: segment.text, })).alignments; @@ -523,7 +529,8 @@ export async function POST(request: NextRequest) { let alignment: TTSSegmentManifestItem['alignment'] = null; try { const whisperBytes = Uint8Array.from(persistedBuffer); - const aligned = (await getCompute().alignWords({ + const computeBackend = await getComputeBackend(); + const aligned = (await computeBackend.alignWords({ audioBuffer: whisperBytes.buffer, audioObjectKey: audioKey, text: segment.text, diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index 4424f1f..5442429 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -2,21 +2,23 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; import { WorkerComputeBackend } from '@/lib/server/compute/worker'; -let backend: ComputeBackend | null = null; +let backendPromise: Promise | null = null; -function createBackend(): ComputeBackend { +async function createBackend(): Promise { const mode: ComputeMode = readComputeMode(); if (mode === 'worker') return new WorkerComputeBackend(); - // Intentionally lazy-load local compute to avoid tracing heavy ONNX - // dependencies unless the backend is actually local. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { LocalComputeBackend } = require('@/lib/server/compute/local') as typeof import('@/lib/server/compute/local'); + const { LocalComputeBackend } = await import('@/lib/server/compute/local'); return new LocalComputeBackend(); } -export function getCompute(): ComputeBackend { - if (!backend) backend = createBackend(); - return backend; +export async function getCompute(): Promise { + if (!backendPromise) { + backendPromise = createBackend().catch((error) => { + backendPromise = null; + throw error; + }); + } + return backendPromise; } export function isComputeAvailable(): boolean { diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index 1410949..c64cea9 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -28,7 +28,8 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { .set({ parseStatus: 'running' }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); - const parsed = await getCompute().parsePdfLayout({ + const compute = await getCompute(); + const parsed = await compute.parsePdfLayout({ documentId: input.documentId, namespace: input.namespace, documentObjectKey: documentKey(input.documentId, input.namespace), From 84c3b22e5a649402cd8352d52fcef030da3f0b92 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 23:02:41 -0600 Subject: [PATCH 015/137] refactor(pdf-layout): streamline viewer layout settling and harden pdf document loading Unify and improve layout settling logic across EPUB, HTML, and PDF viewers by adding delayed recomputation and guarding against zero-height container during transient states. Refactor PDF document loading to include retry logic with success detection, ensuring robust handling of transient failures. Update Playwright test helpers to provide more reliable PDF viewer readiness checks and introduce request retry utilities for backend API calls. Add targeted unit tests for single-section chapter fallback in the PDF audiobook adapter. Update test files and helpers to align with new layout and export logic. --- src/app/(app)/epub/[id]/page.tsx | 14 ++++- src/app/(app)/html/[id]/page.tsx | 14 ++++- src/app/(app)/pdf/[id]/page.tsx | 34 ++++++++++-- src/app/(app)/pdf/[id]/usePdfDocument.ts | 38 ++++--------- tests/export.spec.ts | 67 +++++++++++++++++++---- tests/files/sample.pdf | Bin 74469 -> 51655 bytes tests/helpers.ts | 43 +++++++++++++-- tests/unit/pdf-audiobook-adapter.spec.ts | 63 +++++++++++++++++++++ 8 files changed, 217 insertions(+), 56 deletions(-) diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 5765461..5d7904d 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -111,7 +111,9 @@ export default function EPUBPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // compute max horizontal padding while preserving a minimum readable width, // but still allow some padding on small screens @@ -122,9 +124,15 @@ export default function EPUBPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); // Nudge EPUB renderer to reflow on horizontal padding changes useEffect(() => { diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index d3f6e55..83dbdcd 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -111,7 +111,9 @@ export default function HTMLPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // Adaptive minimum content width: allow some padding on narrow screens const vw = window.innerWidth; @@ -121,9 +123,15 @@ export default function HTMLPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index ba23846..4cfb922 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -79,6 +79,7 @@ export default function PDFViewerPage() { console.log('Loading new document (from page.tsx)'); let didRedirect = false; let startedLoad = false; + let loadSucceeded = false; try { if (!id) { setError('Document not found'); @@ -101,8 +102,20 @@ export default function PDFViewerPage() { startedLoad = true; inFlightDocIdRef.current = resolved; stop(); // Reset TTS when loading new document - await setCurrentDocument(resolved); - loadedDocIdRef.current = resolved; + for (let attempt = 0; attempt < 2; attempt += 1) { + const loaded = await setCurrentDocument(resolved); + if (loaded) { + loadSucceeded = true; + loadedDocIdRef.current = resolved; + break; + } + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + if (!loadSucceeded) { + throw new Error(`Failed to load PDF document ${resolved}`); + } } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -110,7 +123,7 @@ export default function PDFViewerPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad) { + if (!didRedirect && startedLoad && loadSucceeded) { setIsLoading(false); } } @@ -148,12 +161,21 @@ export default function PDFViewerPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + // Avoid locking the reader at 0px during transient startup layout states. + if (h > 0) { + setContainerHeight(`${h}px`); + } }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, isParseReady, isAtLimit, activeSidebar]); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 90fe3ec..9c89201 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -37,7 +37,6 @@ import { highlightWordIndex, } from '@/lib/client/pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; -import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings, @@ -72,7 +71,7 @@ export interface PdfDocumentState { parsedOverlayEnabled: boolean; setParsedOverlayEnabled: (enabled: boolean) => void; forceReparseParsedPdf: () => Promise; - setCurrentDocument: (id: string) => Promise; + setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; // PDF functionality @@ -264,24 +263,6 @@ export function usePdfDocument(): PdfDocumentState { return; } - const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { - const page = pageFromParsed(pageNum); - if (!page) return []; - const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); - return page.blocks - .filter((block) => !skipKinds.has(block.kind)) - .map((block) => ({ - sourceKey: `pdf:${pageNum}:${block.id}`, - text: block.text, - locator: { - readerType: 'pdf', - page: block.fragments[0]?.page ?? pageNum, - blockId: block.id, - } as TTSSegmentLocator, - })) - .filter((unit) => unit.text.trim().length > 0); - }; - const getPageText = async (pageNumber: number, shouldCache = false): Promise => { // Ignore stale/in-flight work if the document or worker changed. if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { @@ -345,14 +326,12 @@ export function usePdfDocument(): PdfDocumentState { if (text !== currDocText || text === '') { setCurrDocText(text); - const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { location: currDocPageNumber, previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, upcomingLocations: additionalUpcoming, - ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { @@ -389,7 +368,7 @@ export function usePdfDocument(): PdfDocumentState { * @param {string} id - The unique identifier of the document to set * @returns {Promise} */ - const setCurrentDocument = useCallback(async (id: string): Promise => { + const setCurrentDocument = useCallback(async (id: string): Promise => { // --- race-condition guard --- const seq = ++docLoadSeqRef.current; docLoadAbortRef.current?.abort(); @@ -416,10 +395,10 @@ export function usePdfDocument(): PdfDocumentState { setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (!meta) { console.error('Document not found on server'); - return; + return false; } if (meta.type === 'pdf') { startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null); @@ -427,25 +406,28 @@ export function usePdfDocument(): PdfDocumentState { } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (doc.type !== 'pdf') { console.error('Document is not a PDF'); - return; + return false; } setCurrDocName(doc.name); // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); + return true; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; + if (error instanceof DOMException && error.name === 'AbortError') return false; console.error('Failed to get document:', error); + return false; } finally { // Clean up the controller only if it's still ours (a newer call hasn't replaced it). if (docLoadAbortRef.current === controller) { docLoadAbortRef.current = null; } } + return false; }, [ setCurrDocId, setCurrDocName, diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d8137a0..d89a445 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '@playwright/test'; +import type { APIResponse } from '@playwright/test'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -8,6 +9,35 @@ import { setupTest, uploadAndDisplay } from './helpers'; const execFileAsync = util.promisify(execFile); +function isTransientRequestError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message || ''; + return ( + msg.includes('ECONNRESET') || + msg.includes('socket hang up') || + msg.includes('ERR_SOCKET_CLOSED') || + msg.includes('ETIMEDOUT') || + msg.includes('fetch failed') + ); +} + +async function requestWithRetry( + fn: () => Promise, + { attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {}, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (!isTransientRequestError(error) || attempt === attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt)); + } + } + throw lastError instanceof Error ? lastError : new Error('Request failed'); +} + async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') { const url = new URL(page.url()); const segments = url.pathname.split('/').filter(Boolean); @@ -103,7 +133,7 @@ async function getAudioDurationSeconds(filePath: string) { } async function expectChaptersBackendState(page: Page, bookId: string) { - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`)); expect(res.ok()).toBeTruthy(); const json = await res.json(); return json; @@ -184,11 +214,20 @@ async function cancelGenerationIfVisible(page: Page): Promise { } async function resetAudiobookById(page: Page, bookId: string) { - const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`)); expect(res.ok() || res.status() === 404).toBeTruthy(); } -async function resetAudiobookIfPresent(page: Page) { +async function resetAudiobookIfPresent(page: Page, bookId?: string) { + // Prefer backend reset when bookId is available: deterministic and independent of modal timing. + if (bookId) { + await resetAudiobookById(page, bookId); + await page.reload(); + await openExportModal(page); + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); + return; + } + const resetButtons = page.getByRole('button', { name: 'Reset' }); const count = await resetButtons.count(); @@ -199,8 +238,12 @@ async function resetAudiobookIfPresent(page: Page) { const resetButton = resetButtons.first(); await resetButton.click(); - await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 }); - const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); + const resetDialog = page + .getByRole('dialog') + .filter({ has: page.getByRole('heading', { name: 'Reset Audiobook' }) }) + .first(); + await expect(resetDialog).toBeVisible({ timeout: 15_000 }); + const confirmReset = resetDialog.getByRole('button', { name: 'Reset' }); await confirmReset.click(); await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); @@ -256,7 +299,7 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => { @@ -312,7 +355,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { @@ -343,7 +386,7 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { @@ -455,7 +498,7 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => { @@ -476,7 +519,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD // Delete the first chapter via the backend API so the audiobook has a missing index (0). // This is more reliable than clicking through the chapter actions menu in headless runs. - const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`); + const deleteRes = await requestWithRetry(() => + page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`) + ); expect(deleteRes.ok()).toBeTruthy(); // Wait for backend to reflect only one remaining chapter (index 1). @@ -534,5 +579,5 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); diff --git a/tests/files/sample.pdf b/tests/files/sample.pdf index 9e0e7e95fe5cd92ea21396ec7d4a8d8a7eb87e92..f08b7aa432a1147dd31ade1ab79f723e58536d05 100644 GIT binary patch literal 51655 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 literal 74469 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 I6KzKS6IXE`;Q#;t diff --git a/tests/helpers.ts b/tests/helpers.ts index 5a7a0f5..d002522 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,6 +22,21 @@ function sha256HexOfFile(filePath: string) { return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +async function waitForPdfViewerReady(page: Page, timeout = 60000) { + await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) }); + await expect + .poll(async () => { + const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false); + if (documentVisible) return true; + const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false); + if (pageVisible) return true; + const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false); + if (canvasVisible) return true; + return false; + }, { timeout }) + .toBe(true); +} + /** * Upload a sample epub or pdf */ @@ -63,7 +78,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { await expect(targetLink).toBeVisible({ timeout: 15000 }); await dismissOnboardingModals(page); await targetLink.click(); - await page.waitForSelector('.react-pdf__Document', { timeout: 15000 }); + await waitForPdfViewerReady(page, 60000); return; } @@ -80,7 +95,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { } if (lower.endsWith('.pdf')) { - await page.waitForSelector('.react-pdf__Document', { timeout: 10000 }); + await waitForPdfViewerReady(page, 60000); } else if (lower.endsWith('.epub')) { await page.waitForSelector('.epub-container', { timeout: 10000 }); } else if (lower.endsWith('.txt') || lower.endsWith('.md')) { @@ -350,10 +365,28 @@ export async function ensureDocumentsListed(page: Page, fileNames: string[]) { // Click the document link row by filename export async function clickDocumentLink(page: Page, fileName: string) { - await page + const link = page .getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }) - .first() - .click(); + .first(); + await expect(link).toBeVisible({ timeout: 15_000 }); + + const href = await link.getAttribute('href'); + if (!href) { + await link.click(); + return; + } + + const navigatedByClick = await Promise.all([ + page + .waitForURL((url) => url.pathname === href, { timeout: 8_000 }) + .then(() => true) + .catch(() => false), + link.click(), + ]).then(([ok]) => ok); + + if (!navigatedByClick) { + await page.goto(href); + } } // Expect correct URL and viewer to be visible for a given file by extension diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index c9d9bd8..20c7114 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -73,4 +73,67 @@ test.describe('pdf audiobook adapter', () => { expect(chapters[1].title).toBe('Second'); expect(chapters[1].text).toContain('More body.'); }); + + test('keeps a single section chapter when only one heading is present', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-2', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'p1-title', + kind: 'doc_title', + text: 'Sample PDF', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Sample PDF', readingOrder: 0 }], + }, + { + id: 'p1-text', + kind: 'text', + text: 'First page body.', + fragments: [{ page: 1, bbox: [0, 50, 100, 79], text: 'First page body.', readingOrder: 1 }], + }, + ], + }, + { + pageNumber: 2, + width: 100, + height: 100, + blocks: [ + { + id: 'p2-text', + kind: 'text', + text: 'Second page body.', + fragments: [{ page: 2, bbox: [0, 50, 100, 79], text: 'Second page body.', readingOrder: 0 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [], + chaptersFromSections: true, + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + smartSentenceSplitting: false, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(1); + expect(chapters[0].title).toBe('Sample PDF'); + expect(chapters[0].text).toContain('First page body.'); + expect(chapters[0].text).toContain('Second page body.'); + }); }); From 8439216dd876f17e55263959ff203a424a06d548 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 19 May 2026 23:19:53 -0600 Subject: [PATCH 016/137] test(pdf-upload): update assertions for PDF viewer heading and page navigation Replace text-based assertion for PDF title with role-based heading check and add assertion for visible page navigation button. Improves test robustness against UI changes and ensures accessibility compliance. --- tests/upload.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index 6a565e2..b95e7cd 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -77,7 +77,8 @@ test.describe('Document Upload Tests', () => { await expectViewerForFile(page, 'sample.pdf'); // Additional content checks specific to the sample PDF await expect(page.locator('.react-pdf__Page')).toBeVisible(); - await expect(page.getByText('Sample PDF')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'sample.pdf' })).toBeVisible(); + await expect(page.getByRole('button', { name: /1\s*\/\s*2/ })).toBeVisible(); }); test('displays an EPUB document', async ({ page }) => { From 0798cd845fc91fe1583490901b2849cb3c4f68b4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 03:44:09 -0600 Subject: [PATCH 017/137] refactor(export): extract download trigger helper and add retry logic to full download Move download logic into reusable downloadViaTrigger function. Add retries and button enabled checks to downloadFullAudiobook for improved robustness against transient UI or backend delays. --- tests/export.spec.ts | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d89a445..8d9e048 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -77,12 +77,14 @@ type DownloadedAudiobook = { cleanup: () => Promise; }; -async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise { - const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); - await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); +async function downloadViaTrigger( + page: Page, + trigger: () => Promise, + timeoutMs = 60_000, +): Promise { const [download] = await Promise.all([ page.waitForEvent('download', { timeout: timeoutMs }), - fullDownloadButton.click(), + trigger(), ]); const failure = await download.failure(); expect(failure).toBeNull(); @@ -119,6 +121,27 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise { + const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); + await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); + await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs }); + + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + return await downloadViaTrigger(page, () => fullDownloadButton.click(), timeoutMs); + } catch (error) { + lastError = error; + if (attempt === 3) throw error; + await page.waitForTimeout(250 * attempt); + await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); + await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs }); + } + } + + throw lastError instanceof Error ? lastError : new Error('Full download failed'); +} + async function getAudioDurationSeconds(filePath: string) { const { stdout } = await execFileAsync('ffprobe', [ '-v', @@ -359,6 +382,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async }); test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { + test.setTimeout(60_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -378,10 +402,9 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page // Readiness gate: chapter row visibility can lead backend storage consistency by a small window. await waitForBackendDownloadReady(page, bookId, { minChapters: 1 }); - // Download via frontend button + // Download via full-download button once at least one chapter is ready. await withDownloadedFullAudiobook(page, async ({ filePath }) => { const durationSeconds = await getAudioDurationSeconds(filePath); - // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. expect(durationSeconds).toBeGreaterThan(9); expect(durationSeconds).toBeLessThan(300); }); From 9d1f9d5707e70fb130298c85f2db863f7550c570 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 03:53:25 -0600 Subject: [PATCH 018/137] fix(pdf): readd source unit extraction for TTS integration in page parsing Introduce sourceUnitsFromParsedPage helper to extract CanonicalTtsSourceUnit objects from parsed PDF pages, filtering by block kind and text content. Update TTS state setter to include extracted source units when available, enabling improved TTS segment mapping and future extensibility. --- src/app/(app)/pdf/[id]/usePdfDocument.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 9c89201..f8f5cca 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -37,6 +37,7 @@ import { highlightWordIndex, } from '@/lib/client/pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; +import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings, @@ -263,6 +264,24 @@ export function usePdfDocument(): PdfDocumentState { return; } + const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { + const page = pageFromParsed(pageNum); + if (!page) return []; + const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + return page.blocks + .filter((block) => !skipKinds.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: block.fragments[0]?.page ?? pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); + }; + const getPageText = async (pageNumber: number, shouldCache = false): Promise => { // Ignore stale/in-flight work if the document or worker changed. if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { @@ -326,12 +345,14 @@ export function usePdfDocument(): PdfDocumentState { if (text !== currDocText || text === '') { setCurrDocText(text); + const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { location: currDocPageNumber, previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, upcomingLocations: additionalUpcoming, + ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { From bb3eb409664d12c908525328cc0332429ada1d26 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 04:44:47 -0600 Subject: [PATCH 019/137] refactor(tts): extend segment prefetching to support source unit arrays and enhance cache key granularity Update TTS segment prefetching logic to handle arrays of CanonicalTtsSourceUnit for upcoming and next locations, enabling more precise TTS segment mapping. Expand cache key construction to include providerType and instructions, improving cache differentiation for TTS requests. Adjust related types and usages to support richer segment metadata propagation. --- src/app/(app)/pdf/[id]/page.tsx | 15 +--- src/app/(app)/pdf/[id]/usePdfDocument.ts | 5 +- src/components/documents/DocumentSettings.tsx | 9 -- src/components/reader/SegmentsSidebar.tsx | 2 + src/contexts/TTSContext.tsx | 83 ++++++++++++++----- src/lib/client/audiobooks/adapters/pdf.ts | 6 -- src/lib/shared/document-settings.ts | 5 -- src/types/document-settings.ts | 2 - tests/unit/pdf-audiobook-adapter.spec.ts | 2 - 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 4cfb922..bad2b1e 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -337,7 +337,6 @@ export default function PDFViewerPage() { parseStatus, parsedOverlayEnabled, skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], - chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), onToggleSkipKind: (kind, enabled) => { const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); @@ -347,20 +346,8 @@ export default function PDFViewerPage() { ...documentSettings, schemaVersion: 1, pdf: { - ...(documentSettings.pdf ?? { chaptersFromSections: true }), + ...(documentSettings.pdf ?? {}), skipBlockKinds: Array.from(current), - chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, - }, - }); - }, - onToggleChaptersFromSections: (enabled) => { - void updateDocumentSettings({ - ...documentSettings, - schemaVersion: 1, - pdf: { - ...(documentSettings.pdf ?? { skipBlockKinds: [] }), - skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], - chaptersFromSections: enabled, }, }); }, diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index f8f5cca..5aafa0a 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -275,7 +275,7 @@ export function usePdfDocument(): PdfDocumentState { text: block.text, locator: { readerType: 'pdf', - page: block.fragments[0]?.page ?? pageNum, + page: pageNum, blockId: block.id, } as TTSSegmentLocator, })) @@ -328,11 +328,13 @@ export function usePdfDocument(): PdfDocumentState { ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)), ]); const nextText = upcomingTexts[0]; + const nextSourceUnits = nextPageNumber ? sourceUnitsFromParsedPage(nextPageNumber) : []; const additionalUpcoming = upcomingPageNumbers .slice(1) .map((pageNum, idx) => ({ location: pageNum, text: upcomingTexts[idx + 1] || '', + sourceUnits: sourceUnitsFromParsedPage(pageNum), })) .filter((item) => item.text.trim().length > 0); @@ -351,6 +353,7 @@ export function usePdfDocument(): PdfDocumentState { previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, + nextSourceUnits, upcomingLocations: additionalUpcoming, ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 9a5ec8a..27d1576 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -103,10 +103,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { parseStatus: PdfParseStatus | null; parsedOverlayEnabled: boolean; skipBlockKinds: ParsedPdfBlockKind[]; - chaptersFromSections: boolean; onToggleOverlay: (enabled: boolean) => void; onToggleSkipKind: (kind: ParsedPdfBlockKind, enabled: boolean) => void; - onToggleChaptersFromSections: (enabled: boolean) => void; onForceReparse: () => void; } }) { @@ -204,13 +202,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)} variant="flat" /> - )} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 1e79ccc..5c9b2f1 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -249,6 +249,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const segmentsQuery = useInfiniteQuery({ queryKey: segmentsQueryKey, enabled: isOpen && !!documentId, + refetchInterval: isOpen ? 2500 : false, + refetchIntervalInBackground: false, initialPageParam: null as string | null, queryFn: async ({ pageParam, signal }) => { if (!documentId) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index d96d23e..b380bcd 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -163,8 +163,9 @@ interface SetTextOptions { previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; + nextSourceUnits?: CanonicalTtsSourceUnit[]; previousText?: string; - upcomingLocations?: Array<{ location: TTSLocation; text: string }>; + upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>; } type TTSSegmentPlaybackSource = { @@ -245,12 +246,16 @@ const buildCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, ) => { return [ `provider=${provider || ''}`, + `providerType=${providerType || ''}`, `model=${model || ''}`, `voice=${voice || ''}`, `speed=${Number.isFinite(speed) ? speed : ''}`, + `instructions=${instructions || ''}`, `text=${sentence}`, ].join('|'); }; @@ -263,10 +268,12 @@ const buildScopedSegmentCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, segmentKey?: string | null, ) => { return [ - buildCacheKey(sentence, voice, speed, provider, model), + buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions), `segmentKey=${segmentKey || ''}`, `locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`, `segmentIndex=${segmentKey ? '' : segmentIndex}`, @@ -306,9 +313,11 @@ const buildSegmentRequestKey = ( segmentIndex: number, sentence: string, segmentKey?: string | null, -): string => segmentKey - ? `${segmentKey}::${sentence}` - : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +): string => { + return segmentKey + ? `${segmentKey}::${sentence}` + : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +}; const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { if (input.newSentenceCount <= 0) { @@ -1172,29 +1181,48 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; - const pendingPrefetches: Array = []; + const pendingPrefetches: Array<{ + location: TTSLocation; + sourceUnits: CanonicalTtsSourceUnit[]; + }> = []; if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), - location: normalizedOptions.nextLocation, - text: normalizedOptions.nextText, - locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), - }); + const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: normalizedOptions.nextLocation, + sourceUnits: provided, + } + : { + location: normalizedOptions.nextLocation, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), + text: normalizedOptions.nextText, + locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), + }], + }); } if (Array.isArray(normalizedOptions.upcomingLocations)) { for (const item of normalizedOptions.upcomingLocations) { if (item.location === undefined || !item.text?.trim()) continue; - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(item.location, currDocPage), - location: item.location, - text: item.text, - locator: locatorForLocation(item.location, activeReaderType), - }); + const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: item.location, + sourceUnits: provided, + } + : { + location: item.location, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(item.location, currDocPage), + text: item.text, + locator: locatorForLocation(item.location, activeReaderType), + }], + }); } } for (const item of pendingPrefetches) { if (smartSentenceSplitting) { - sourceUnits.push(item); + sourceUnits.push(...item.sourceUnits); } } @@ -1216,9 +1244,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { + const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); const planned = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === item.sourceKey) - : planCanonicalTtsSegments([item], { + ? plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)) + : planCanonicalTtsSegments(item.sourceUnits, { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), @@ -1554,6 +1583,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segmentKey, ); @@ -2216,6 +2247,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', playbackSegment?.key, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); @@ -2346,6 +2379,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', nextSegment?.key, ); if (segmentManifestCacheRef.current.has(cacheKey)) { @@ -2477,6 +2512,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); if (seenCandidates.has(requestKey)) continue; @@ -2615,6 +2652,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', plannedSegment?.key, ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key); @@ -2644,6 +2683,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key); diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 9aff5ac..a462c32 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -41,12 +41,6 @@ function prepareParsedChapters({ .filter((block) => !skip.has(block.kind)); if (!allBlocks.length) return []; - const chaptersFromSections = settings.pdf?.chaptersFromSections ?? true; - if (!chaptersFromSections) { - const text = chapterTextFromBlocks(allBlocks, smartSentenceSplitting, maxBlockLength); - return text ? [{ index: 0, title: 'Document', text }] : []; - } - const chapters: PreparedAudiobookChapter[] = []; let currentTitle = 'Introduction'; let currentBlocks: ParsedPdfBlock[] = []; diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts index 323a039..26b45e0 100644 --- a/src/lib/shared/document-settings.ts +++ b/src/lib/shared/document-settings.ts @@ -24,7 +24,6 @@ export function mergeDocumentSettings( schemaVersion: 1, pdf: { skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], - chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true, }, }; @@ -38,10 +37,6 @@ export function mergeDocumentSettings( schemaVersion: 1, pdf: { skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds), - chaptersFromSections: - typeof pdfRec.chaptersFromSections === 'boolean' - ? pdfRec.chaptersFromSections - : (base.pdf?.chaptersFromSections ?? true), }, }; } diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts index 1bfd1ba..6908f33 100644 --- a/src/types/document-settings.ts +++ b/src/types/document-settings.ts @@ -4,7 +4,6 @@ export interface DocumentSettings { schemaVersion: 1; pdf?: { skipBlockKinds: ParsedPdfBlockKind[]; - chaptersFromSections: boolean; }; } @@ -12,6 +11,5 @@ export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { schemaVersion: 1, pdf: { skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'], - chaptersFromSections: true, }, }; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index 20c7114..4acdeaa 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -55,7 +55,6 @@ test.describe('pdf audiobook adapter', () => { schemaVersion: 1, pdf: { skipBlockKinds: ['header'], - chaptersFromSections: true, }, }; @@ -120,7 +119,6 @@ test.describe('pdf audiobook adapter', () => { schemaVersion: 1, pdf: { skipBlockKinds: [], - chaptersFromSections: true, }, }; From b679bf736cd541d13b3a6fe8fd096a433d1857ca Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 04:51:10 -0600 Subject: [PATCH 020/137] chore(config): remove smartSentenceSplitting option and related code paths Eliminate the smartSentenceSplitting configuration flag from application state, UI, and all TTS and audiobook adapter logic. Consolidate TTS segment planning to always include context units, streamlining code and reducing conditional branches. Update tests and type definitions to reflect the removal of this feature toggle. --- src/app/(app)/epub/[id]/useEpubDocument.ts | 26 ++--- src/app/(app)/html/[id]/useHtmlDocument.ts | 4 +- src/app/(app)/pdf/[id]/usePdfDocument.ts | 39 ++----- src/app/api/user/state/preferences/route.ts | 1 - src/components/documents/DocumentSettings.tsx | 8 -- src/contexts/ConfigContext.tsx | 3 - src/contexts/TTSContext.tsx | 32 +----- src/lib/client/audiobooks/adapters/html.ts | 6 +- src/lib/client/audiobooks/adapters/pdf.ts | 10 +- src/lib/client/dexie.ts | 2 - src/lib/client/epub/tts-epub-preload.ts | 2 - src/lib/client/pdf-tts-planning.ts | 58 ++++++++++ src/types/config.ts | 2 - src/types/user-state.ts | 1 - tests/unit/html-audiobook-adapter.spec.ts | 8 -- tests/unit/pdf-audiobook-adapter.spec.ts | 2 - tests/unit/pdf-tts-planning.spec.ts | 107 ++++++++++++++++++ tests/unit/tts-epub-preload.spec.ts | 17 +-- 18 files changed, 197 insertions(+), 131 deletions(-) create mode 100644 src/lib/client/pdf-tts-planning.ts create mode 100644 tests/unit/pdf-tts-planning.spec.ts diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index d487ce2..8c5a78a 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -101,7 +101,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { baseUrl, providerRef, ttsSegmentMaxBlockLength, - smartSentenceSplitting, epubTheme, epubHighlightEnabled, } = useConfig(); @@ -239,22 +238,13 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); - if (smartSentenceSplitting) { - setTTSText(textContent, { - shouldPause, - location: start.cfi, - previousText: leadingPreview, - nextLocation: end.cfi, - nextText: continuationPreview - }); - } else { - // When smart splitting is disabled, behave like the original implementation: - // send only the current page/location text without any continuation preview. - setTTSText(textContent, { - shouldPause, - location: start.cfi, - }); - } + setTTSText(textContent, { + shouldPause, + location: start.cfi, + previousText: leadingPreview, + nextLocation: end.cfi, + nextText: continuationPreview + }); setCurrDocText(textContent); return textContent; @@ -262,7 +252,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.error('Error extracting EPUB text:', error); return ''; } - }, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]); + }, [setRenderedTextMaps, setTTSText]); /** * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts index e8e854d..fe23c0f 100644 --- a/src/app/(app)/html/[id]/useHtmlDocument.ts +++ b/src/app/(app)/html/[id]/useHtmlDocument.ts @@ -68,7 +68,6 @@ export function useHtmlDocument(): HtmlDocumentState { apiKey, baseUrl, providerRef, - smartSentenceSplitting, ttsSegmentMaxBlockLength, } = useConfig(); @@ -133,10 +132,9 @@ export function useHtmlDocument(): HtmlDocumentState { createHtmlAudiobookSourceAdapter({ blocks, isTxt, - smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, }), - [blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength], + [blocks, isTxt, ttsSegmentMaxBlockLength], ); const createFullAudioBook = useCallback( diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 5aafa0a..3476931 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -37,6 +37,7 @@ import { highlightWordIndex, } from '@/lib/client/pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; +import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '@/lib/client/pdf-tts-planning'; import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, @@ -130,7 +131,6 @@ export function usePdfDocument(): PdfDocumentState { apiKey, baseUrl, providerRef, - smartSentenceSplitting, segmentPreloadDepthPages, ttsSegmentMaxBlockLength, } = useConfig(); @@ -149,9 +149,8 @@ export function usePdfDocument(): PdfDocumentState { const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ parsed: parsedDocument ?? undefined, settings: documentSettings, - smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [parsedDocument, documentSettings, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [parsedDocument, documentSettings, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -266,20 +265,7 @@ export function usePdfDocument(): PdfDocumentState { const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { const page = pageFromParsed(pageNum); - if (!page) return []; - const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); - return page.blocks - .filter((block) => !skipKinds.has(block.kind)) - .map((block) => ({ - sourceKey: `pdf:${pageNum}:${block.id}`, - text: block.text, - locator: { - readerType: 'pdf', - page: pageNum, - blockId: block.id, - } as TTSSegmentLocator, - })) - .filter((unit) => unit.text.trim().length > 0); + return buildPdfPageSourceUnits(page, pageNum, documentSettings.pdf?.skipBlockKinds ?? []); }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { @@ -327,16 +313,15 @@ export function usePdfDocument(): PdfDocumentState { prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve(undefined), ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)), ]); - const nextText = upcomingTexts[0]; - const nextSourceUnits = nextPageNumber ? sourceUnitsFromParsedPage(nextPageNumber) : []; - const additionalUpcoming = upcomingPageNumbers - .slice(1) - .map((pageNum, idx) => ({ - location: pageNum, - text: upcomingTexts[idx + 1] || '', - sourceUnits: sourceUnitsFromParsedPage(pageNum), - })) - .filter((item) => item.text.trim().length > 0); + const { + nextText, + nextSourceUnits, + additionalUpcoming, + } = buildPdfPrefetchPayload( + upcomingPageNumbers, + upcomingTexts, + sourceUnitsFromParsedPage, + ); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { return; diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 96191fe..34c5e54 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -137,7 +137,6 @@ function sanitizePreferencesPatch( break; case 'skipBlank': case 'epubTheme': - case 'smartSentenceSplitting': case 'pdfHighlightEnabled': case 'pdfWordHighlightEnabled': case 'epubHighlightEnabled': diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 27d1576..0516b02 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -113,7 +113,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { viewType, skipBlank, epubTheme, - smartSentenceSplitting, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, @@ -272,13 +271,6 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { /> )} - updateConfigKey('smartSentenceSplitting', checked)} - variant="flat" - />
unit.sourceKey)); const contextSourceUnits: CanonicalTtsSourceUnit[] = []; - if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { + if (normalizedOptions.previousText?.trim()) { const previousLocation = normalizedOptions.previousLocation; contextSourceUnits.push({ sourceKey: previousLocation !== undefined @@ -1221,9 +1220,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } } for (const item of pendingPrefetches) { - if (smartSentenceSplitting) { - sourceUnits.push(...item.sourceUnits); - } + sourceUnits.push(...item.sourceUnits); } const plan = planCanonicalTtsSegments(sourceUnits, { @@ -1232,27 +1229,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }); - const currentSegments = smartSentenceSplitting - ? plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)) - : effectiveCurrentUnits.flatMap((unit) => - planCanonicalTtsSegments([unit], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, - }).segments); + const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); - const planned = smartSentenceSplitting - ? plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)) - : planCanonicalTtsSegments(item.sourceUnits, { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, - }).segments; + const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } @@ -1333,8 +1315,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); if ( - smartSentenceSplitting - && !isEPUB + !isEPUB && normalizedOptions.nextLocation !== undefined && effectiveCurrentUnits.length === 1 ) { @@ -1384,7 +1365,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio, isEPUB, activeReaderType, - smartSentenceSplitting, invalidatePlaybackRun, currDocPage, documentId, @@ -2483,7 +2463,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? currentSourceContextUnitsRef.current : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( - smartSentenceSplitting, liveContextUnits, upcomingUnits, ); @@ -2835,7 +2814,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement openApiBaseUrl, providerModelPolicy.supportsInstructions, ttsInstructions, - smartSentenceSplitting, onTTSStart, onTTSComplete, processSentence, diff --git a/src/lib/client/audiobooks/adapters/html.ts b/src/lib/client/audiobooks/adapters/html.ts index 9880c5a..f44e8ca 100644 --- a/src/lib/client/audiobooks/adapters/html.ts +++ b/src/lib/client/audiobooks/adapters/html.ts @@ -5,7 +5,6 @@ import type { HtmlBlock } from '@/lib/client/html/blocks'; interface HtmlAudiobookAdapterOptions { blocks: HtmlBlock[]; isTxt: boolean; - smartSentenceSplitting: boolean; maxBlockLength?: number; /** * For markdown: any heading with `headingLevel <= chapterHeadingLevel` @@ -83,7 +82,6 @@ function buildChapterDrafts({ function chapterText( draft: ChapterDraft, - smartSentenceSplitting: boolean, maxBlockLength?: number, ): string { const joined = draft.blocks @@ -91,14 +89,14 @@ function chapterText( .filter((t) => t && t.trim()) .join('\n\n'); if (!joined) return ''; - return smartSentenceSplitting ? normalizeTextForTts(joined, { maxBlockLength }) : joined; + return normalizeTextForTts(joined, { maxBlockLength }); } function preparedChapters(options: HtmlAudiobookAdapterOptions): PreparedAudiobookChapter[] { const drafts = buildChapterDrafts(options); const out: PreparedAudiobookChapter[] = []; for (const draft of drafts) { - const text = chapterText(draft, options.smartSentenceSplitting, options.maxBlockLength); + const text = chapterText(draft, options.maxBlockLength); if (!text.trim()) continue; out.push({ index: out.length, diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index a462c32..19c86e8 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -7,13 +7,11 @@ import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { parsed?: ParsedPdfDocument; settings?: DocumentSettings; - smartSentenceSplitting: boolean; maxBlockLength?: number; } function chapterTextFromBlocks( blocks: ParsedPdfBlock[], - smartSentenceSplitting: boolean, maxBlockLength?: number, ): string { const text = blocks @@ -21,18 +19,16 @@ function chapterTextFromBlocks( .filter(Boolean) .join('\n\n'); if (!text) return ''; - return smartSentenceSplitting ? normalizeTextForTts(text, { maxBlockLength }) : text; + return normalizeTextForTts(text, { maxBlockLength }); } function prepareParsedChapters({ parsed, settings, - smartSentenceSplitting, maxBlockLength, }: { parsed: ParsedPdfDocument; settings: DocumentSettings; - smartSentenceSplitting: boolean; maxBlockLength?: number; }): PreparedAudiobookChapter[] { const skip = new Set(settings.pdf?.skipBlockKinds ?? DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? []); @@ -49,7 +45,7 @@ function prepareParsedChapters({ const flush = () => { if (!currentBlocks.length) return; - const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); + const text = chapterTextFromBlocks(currentBlocks, maxBlockLength); if (text) { chapters.push({ index: chapters.length, @@ -77,7 +73,6 @@ function prepareParsedChapters({ async function extractPreparedPdfChapters({ parsed, settings = DEFAULT_DOCUMENT_SETTINGS, - smartSentenceSplitting, maxBlockLength, }: PdfAudiobookAdapterOptions): Promise { if (!parsed) { @@ -87,7 +82,6 @@ async function extractPreparedPdfChapters({ return prepareParsedChapters({ parsed, settings, - smartSentenceSplitting, maxBlockLength, }); } diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index cf8e3ce..3bb4d0a 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -194,8 +194,6 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { voice: '', skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank, epubTheme: raw.epubTheme === 'true', - smartSentenceSplitting: - raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting, headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin, footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin, leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin, diff --git a/src/lib/client/epub/tts-epub-preload.ts b/src/lib/client/epub/tts-epub-preload.ts index 36858ea..5f23e14 100644 --- a/src/lib/client/epub/tts-epub-preload.ts +++ b/src/lib/client/epub/tts-epub-preload.ts @@ -29,10 +29,8 @@ export function selectUpcomingWalkerItems( * (previous/current) so walker boundary behavior aligns with setText. */ export function buildWalkerPlanningSourceUnits( - smartSentenceSplitting: boolean, contextUnits: readonly CanonicalTtsSourceUnit[], upcomingUnits: readonly CanonicalTtsSourceUnit[], ): CanonicalTtsSourceUnit[] { - if (!smartSentenceSplitting) return [...upcomingUnits]; return [...contextUnits, ...upcomingUnits]; } diff --git a/src/lib/client/pdf-tts-planning.ts b/src/lib/client/pdf-tts-planning.ts new file mode 100644 index 0000000..23e2074 --- /dev/null +++ b/src/lib/client/pdf-tts-planning.ts @@ -0,0 +1,58 @@ +import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +type PdfUpcomingLocation = { + location: number; + text: string; + sourceUnits: CanonicalTtsSourceUnit[]; +}; + +export function buildPdfPageSourceUnits( + page: ParsedPdfPage | undefined, + pageNum: number, + skipKinds: ParsedPdfBlockKind[] = [], +): CanonicalTtsSourceUnit[] { + if (!page) return []; + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); +} + +export function buildPdfPrefetchPayload( + upcomingPageNumbers: number[], + upcomingTexts: string[], + sourceUnitsForPage: (pageNum: number) => CanonicalTtsSourceUnit[], +): { + nextText: string | undefined; + nextSourceUnits: CanonicalTtsSourceUnit[]; + additionalUpcoming: PdfUpcomingLocation[]; +} { + const nextPageNumber = upcomingPageNumbers[0]; + const nextText = upcomingTexts[0]; + const nextSourceUnits = nextPageNumber ? sourceUnitsForPage(nextPageNumber) : []; + const additionalUpcoming = upcomingPageNumbers + .slice(1) + .map((pageNum, idx) => ({ + location: pageNum, + text: upcomingTexts[idx + 1] || '', + sourceUnits: sourceUnitsForPage(pageNum), + })) + .filter((item) => item.text.trim().length > 0); + + return { + nextText, + nextSourceUnits, + additionalUpcoming, + }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 58bb388..53db98c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -62,7 +62,6 @@ export interface AppConfigValues { ttsModel: string; ttsInstructions: string; savedVoices: SavedVoices; - smartSentenceSplitting: boolean; segmentPreloadDepthPages: number; segmentPreloadSentenceLookahead: number; ttsSegmentMaxBlockLength: number; @@ -111,7 +110,6 @@ export function getAppConfigDefaults(): AppConfigValues { ttsModel: defaultModel, ttsInstructions: '', savedVoices: {}, - smartSentenceSplitting: true, segmentPreloadDepthPages: 1, segmentPreloadSentenceLookahead: 3, ttsSegmentMaxBlockLength: 450, diff --git a/src/types/user-state.ts b/src/types/user-state.ts index 7a0e275..3fc796e 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -7,7 +7,6 @@ export const SYNCED_PREFERENCE_KEYS = [ 'voice', 'skipBlank', 'epubTheme', - 'smartSentenceSplitting', 'segmentPreloadDepthPages', 'segmentPreloadSentenceLookahead', 'ttsSegmentMaxBlockLength', diff --git a/tests/unit/html-audiobook-adapter.spec.ts b/tests/unit/html-audiobook-adapter.spec.ts index 15c1a31..9395533 100644 --- a/tests/unit/html-audiobook-adapter.spec.ts +++ b/tests/unit/html-audiobook-adapter.spec.ts @@ -30,7 +30,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']); @@ -46,7 +45,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']); @@ -64,7 +62,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, fallbackBlocksPerChapter: 3, }); const chapters = await adapter.prepareChapters(); @@ -88,7 +85,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, chapterHeadingLevel: 1, }); const chapters = await adapter.prepareChapters(); @@ -109,7 +105,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']); @@ -128,7 +123,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.length).toBe(1); @@ -142,7 +136,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const list = await adapter.prepareChapters(); const second = await adapter.prepareChapter(1); @@ -155,7 +148,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i); }); diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index 4acdeaa..dd110b5 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -61,7 +61,6 @@ test.describe('pdf audiobook adapter', () => { const adapter = createPdfAudiobookSourceAdapter({ parsed, settings, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); @@ -125,7 +124,6 @@ test.describe('pdf audiobook adapter', () => { const adapter = createPdfAudiobookSourceAdapter({ parsed, settings, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); diff --git a/tests/unit/pdf-tts-planning.spec.ts b/tests/unit/pdf-tts-planning.spec.ts new file mode 100644 index 0000000..1066d7b --- /dev/null +++ b/tests/unit/pdf-tts-planning.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '../../src/lib/client/pdf-tts-planning'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +function buildPage(pageNumber: number): ParsedPdfPage { + return { + pageNumber, + width: 800, + height: 1200, + blocks: [ + { + id: `h-${pageNumber}`, + kind: 'header', + text: 'Header text', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'Header text', readingOrder: 0 }], + }, + { + id: `p-${pageNumber}-a`, + kind: 'text', + text: `Paragraph A on page ${pageNumber}.`, + // Regression guard: fragment page can drift; locator must stay pinned + // to the requested page number for stable planning/grouping. + fragments: [{ page: pageNumber + 100, bbox: [0, 0, 1, 1], text: 'x', readingOrder: 1 }], + }, + { + id: `p-${pageNumber}-b`, + kind: 'paragraph_title', + text: `Section title ${pageNumber}`, + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'y', readingOrder: 2 }], + }, + { + id: `empty-${pageNumber}`, + kind: 'text', + text: ' ', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'z', readingOrder: 3 }], + }, + ], + }; +} + +test.describe('pdf tts planning helpers', () => { + test('buildPdfPageSourceUnits uses parsed blocks, honors skip kinds, and pins locator page', () => { + const page = buildPage(2); + const units = buildPdfPageSourceUnits(page, 2, ['header']); + + expect(units.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(units.map((u) => u.text)).toEqual([ + 'Paragraph A on page 2.', + 'Section title 2', + ]); + expect(units.map((u) => u.locator)).toEqual([ + { readerType: 'pdf', page: 2, blockId: 'p-2-a' }, + { readerType: 'pdf', page: 2, blockId: 'p-2-b' }, + ]); + }); + + test('buildPdfPrefetchPayload includes parsed sourceUnits for next and upcoming pages', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + [4, buildPage(4)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3, 4], + ['Page 2 text', 'Page 3 text', 'Page 4 text'], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.nextSourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(payload.additionalUpcoming).toHaveLength(2); + expect(payload.additionalUpcoming[0]).toMatchObject({ + location: 3, + text: 'Page 3 text', + }); + expect(payload.additionalUpcoming[0].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:3:p-3-a', + 'pdf:3:p-3-b', + ]); + expect(payload.additionalUpcoming[1].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:4:p-4-a', + 'pdf:4:p-4-b', + ]); + }); + + test('buildPdfPrefetchPayload drops blank upcoming text entries', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3], + ['Page 2 text', ' '], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.additionalUpcoming).toHaveLength(0); + }); +}); diff --git a/tests/unit/tts-epub-preload.spec.ts b/tests/unit/tts-epub-preload.spec.ts index ea24f88..d780e51 100644 --- a/tests/unit/tts-epub-preload.spec.ts +++ b/tests/unit/tts-epub-preload.spec.ts @@ -30,7 +30,7 @@ test.describe('EPUB walker preload helpers', () => { expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 0)).toEqual([]); }); - test('buildWalkerPlanningSourceUnits includes live context when smart splitting is enabled', () => { + test('buildWalkerPlanningSourceUnits includes live context', () => { const contextUnits: CanonicalTtsSourceUnit[] = [ { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, @@ -40,7 +40,7 @@ test.describe('EPUB walker preload helpers', () => { { sourceKey: 'page-c', text: 'upcoming two', locator: { readerType: 'epub', location: 'page-c' } }, ]; - const planned = buildWalkerPlanningSourceUnits(true, contextUnits, upcomingUnits); + const planned = buildWalkerPlanningSourceUnits(contextUnits, upcomingUnits); expect(planned.map((item) => item.sourceKey)).toEqual([ 'previous:page-a', 'page-a', @@ -48,17 +48,4 @@ test.describe('EPUB walker preload helpers', () => { 'page-c', ]); }); - - test('buildWalkerPlanningSourceUnits uses only upcoming units when smart splitting is disabled', () => { - const contextUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, - { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, - ]; - const upcomingUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } }, - ]; - - const planned = buildWalkerPlanningSourceUnits(false, contextUnits, upcomingUnits); - expect(planned.map((item) => item.sourceKey)).toEqual(['page-b']); - }); }); From 502c98f11f62d3fc691f9161b018dc15e7ac30e4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 04:59:09 -0600 Subject: [PATCH 021/137] refactor(tts): add segment cache clearing and improve sidebar scroll handling Introduce clearSegmentCaches to TTS context for explicit cache invalidation after segment clearing. Enhance SegmentsSidebar scroll logic to better distinguish user-initiated and programmatic scrolls, improving UX during segment updates. --- src/components/reader/SegmentsSidebar.tsx | 48 +++++++++++++++++++++-- src/contexts/TTSContext.tsx | 13 ++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 5c9b2f1..8752f4a 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -185,6 +185,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: isPlaying, playFromSegment, activeReaderType, + clearSegmentCaches, } = useTTS(); const { providerRef, @@ -241,6 +242,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const listRef = useRef(null); const didAutoScrollOnOpenRef = useRef(false); + const userScrollUntilMsRef = useRef(0); + const programmaticScrollUntilMsRef = useRef(0); + const lastSegmentRefreshKeyRef = useRef(''); const segmentsQueryKey = useMemo( () => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const, [documentId], @@ -249,8 +253,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const segmentsQuery = useInfiniteQuery({ queryKey: segmentsQueryKey, enabled: isOpen && !!documentId, - refetchInterval: isOpen ? 2500 : false, - refetchIntervalInBackground: false, initialPageParam: null as string | null, queryFn: async ({ pageParam, signal }) => { if (!documentId) { @@ -321,6 +323,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: return payload; }, onSuccess: async (payload) => { + clearSegmentCaches(); if (payload?.warning) { toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`); } else if (payload) { @@ -354,20 +357,55 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: await clearSegments(); }, [documentId, isClearingSegments, clearSegments]); + useEffect(() => { + if (!isOpen || !isPlaying) return; + const locationKey = activeReaderType === 'epub' + ? String(currDocPage) + : String(currDocPageNumber); + const refreshKey = `${activeReaderType}|${locationKey}|${currentSentenceIndex}`; + if (lastSegmentRefreshKeyRef.current === refreshKey) return; + lastSegmentRefreshKeyRef.current = refreshKey; + void refetchManifest(); + }, [ + isOpen, + isPlaying, + activeReaderType, + currDocPage, + currDocPageNumber, + currentSentenceIndex, + refetchManifest, + ]); + useEffect(() => { if (!isOpen) return; const node = listRef.current; if (!node) return; + const markUserScrollActive = () => { + userScrollUntilMsRef.current = Date.now() + 1200; + }; + const onScroll = () => { + if (Date.now() > programmaticScrollUntilMsRef.current) { + markUserScrollActive(); + } if (!hasMoreManifestPages || isLoadingMoreManifest) return; const distance = node.scrollHeight - node.scrollTop - node.clientHeight; if (distance > 280) return; void fetchNextPage(); }; + const onWheel = () => markUserScrollActive(); + const onTouchMove = () => markUserScrollActive(); + node.addEventListener('scroll', onScroll); - return () => node.removeEventListener('scroll', onScroll); + node.addEventListener('wheel', onWheel, { passive: true }); + node.addEventListener('touchmove', onTouchMove, { passive: true }); + return () => { + node.removeEventListener('scroll', onScroll); + node.removeEventListener('wheel', onWheel); + node.removeEventListener('touchmove', onTouchMove); + }; }, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]); const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { @@ -553,10 +591,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const container = listRef.current; if (!container) return; + if (Date.now() < userScrollUntilMsRef.current) return; const activeRow = container.querySelector('[data-active-segment="true"]'); if (!activeRow) return; requestAnimationFrame(() => { + programmaticScrollUntilMsRef.current = Date.now() + 300; activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); didAutoScrollOnOpenRef.current = true; }); @@ -568,6 +608,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const root = listRef.current; if (!root) return; + if (Date.now() < userScrollUntilMsRef.current) return; const activeRow = root.querySelector('[data-active-segment="true"]'); if (!activeRow) return; @@ -576,6 +617,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return; requestAnimationFrame(() => { + programmaticScrollUntilMsRef.current = Date.now() + 300; activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); }); }, [ diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index d8b83eb..1972448 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -146,6 +146,7 @@ interface TTSContextType extends TTSPlaybackState { setSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; + clearSegmentCaches: () => void; skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void; @@ -2896,6 +2897,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); }, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); + const clearSegmentCaches = useCallback(() => { + // Keep the current viewport/sentence list intact, but force all audio/manifest + // state to be re-resolved after a server-side clear. + abortAudio(true); + segmentManifestCacheRef.current.clear(); + sentenceAlignmentCacheRef.current.clear(); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + }, [abortAudio]); + /** * Stops the current audio playback and starts playing from a specified index * @@ -3107,6 +3118,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, + clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, @@ -3137,6 +3149,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, + clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, From 4b2653117203001174e57032639731a857303950 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 07:38:03 -0600 Subject: [PATCH 022/137] chore(worker): migrate compute worker from Redis/BullMQ to NATS JetStream Replace Redis and BullMQ with NATS JetStream and KV for job queuing and state management in the compute worker service. Update environment variables, Docker Compose, and documentation to reflect the new message queue backend. Adjust dependencies and code to use NATS-based work queue and key-value storage for durable job processing. This change improves scalability and reliability of distributed compute workloads by leveraging NATS JetStream's work queue and persistence features. Documentation and configuration examples are updated to guide deployments using the new backend. --- .env.example | 2 +- README.md | 4 +- compute/worker/.env.example | 5 +- compute/worker/docker-compose.yml | 19 +- compute/worker/package.json | 11 +- compute/worker/src/server.ts | 667 ++++++++----- docs-site/docs/deploy/compute-worker.md | 8 +- docs-site/docs/deploy/local-development.md | 8 +- docs-site/docs/deploy/vercel-deployment.md | 2 +- docs-site/docs/docker-quick-start.md | 2 +- docs-site/docs/introduction.md | 2 +- .../docs/reference/environment-variables.md | 4 +- docs-site/docs/reference/stack.md | 4 +- pnpm-lock.yaml | 919 ++++++++++++++---- 14 files changed, 1184 insertions(+), 473 deletions(-) diff --git a/.env.example b/.env.example index 7457a88..88f6ec0 100644 --- a/.env.example +++ b/.env.example @@ -79,7 +79,7 @@ IMPORT_LIBRARY_DIRS= # Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. # local = run compute in-process (default) -# worker = external durable worker mode (requires Redis-backed compute-worker service) +# worker = external durable worker mode (requires NATS JetStream-backed compute-worker service) COMPUTE_MODE=local # Required when COMPUTE_MODE=worker # COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/README.md b/README.md index ee8d95a..1e6b1ad 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights - 🧱 **Layout-aware PDF parsing** with PP-DocLayoutV3 (ONNX) — structured block detection, cross-page stitching, and geometry-based highlighting for precise read-along sync. -- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment — no external dependencies, runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable Redis-backed worker (`COMPUTE_MODE=worker`). +- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment — no external dependencies, runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable NATS JetStream-backed worker (`COMPUTE_MODE=worker`). - ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback. - 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. @@ -32,7 +32,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader | --- | --- | | Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/docker-quick-start) | | Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/deploy/vercel-deployment) | -| Deploy external compute worker | [Compute Worker (Redis + BullMQ)](https://docs.openreader.richardr.dev/deploy/compute-worker) | +| Deploy external compute worker | [Compute Worker (NATS JetStream)](https://docs.openreader.richardr.dev/deploy/compute-worker) | | Develop locally | [Local Development](https://docs.openreader.richardr.dev/deploy/local-development) | | Configure auth | [Auth](https://docs.openreader.richardr.dev/configure/auth) | | Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/configure/database) | diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 4809e98..4a36fdc 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -7,8 +7,8 @@ COMPUTE_LOG_FORMAT=pretty # App <-> worker auth COMPUTE_WORKER_TOKEN=local-compute-token -# Redis/BullMQ -REDIS_URL=redis://redis:6379 +# NATS/JetStream +NATS_URL=nats://nats:4222 # Shared object storage (must be reachable from worker) S3_BUCKET=openreader-documents @@ -21,5 +21,4 @@ S3_ENDPOINT=http://host.docker.internal:8333 S3_FORCE_PATH_STYLE=true # Queue + execution tuning -COMPUTE_QUEUE_MAX_DEPTH=64 COMPUTE_PREWARM_MODELS=true diff --git a/compute/worker/docker-compose.yml b/compute/worker/docker-compose.yml index 067b2c6..269c2d7 100644 --- a/compute/worker/docker-compose.yml +++ b/compute/worker/docker-compose.yml @@ -1,9 +1,13 @@ services: - redis: - image: redis:7-alpine - container_name: openreader-compute-redis + nats: + image: nats:2.14-alpine + container_name: openreader-compute-nats + command: ["-js", "-sd", "/data"] ports: - - "6379:6379" + - "4222:4222" + - "8222:8222" + volumes: + - nats-data:/data compute-worker: build: @@ -11,11 +15,11 @@ services: dockerfile: compute/worker/Dockerfile container_name: openreader-compute-worker depends_on: - - redis + - nats env_file: - ./.env environment: - REDIS_URL: ${REDIS_URL:-redis://redis:6379} + NATS_URL: ${NATS_URL:-nats://nats:4222} COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0} COMPUTE_WORKER_PORT: ${COMPUTE_WORKER_PORT:-8081} COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token} @@ -39,3 +43,6 @@ services: path: ../../pnpm-lock.yaml - action: rebuild path: ../../pnpm-workspace.yaml + +volumes: + nats-data: diff --git a/compute/worker/package.json b/compute/worker/package.json index a904a29..bd85999 100644 --- a/compute/worker/package.json +++ b/compute/worker/package.json @@ -8,16 +8,17 @@ "start": "tsx src/server.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1045.0", + "@aws-sdk/client-s3": "^3.1050.0", + "@nats-io/jetstream": "^3.4.0", + "@nats-io/kv": "^3.4.0", + "@nats-io/transport-node": "^3.4.0", "@openreader/compute-core": "workspace:*", - "bullmq": "^5.61.2", "fastify": "^5.6.2", - "ioredis": "^5.8.2", - "pino": "^9.14.0", + "pino": "^10.3.1", "pino-pretty": "^13.1.2", "zod": "^4.1.12" }, "devDependencies": { - "tsx": "^4.20.6" + "tsx": "^4.22.3" } } diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index ebbfcf8..8b4263b 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -1,10 +1,25 @@ -import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; -import { Queue, Worker, type Job, type JobsOptions } from 'bullmq'; -import IORedis from 'ioredis'; +import Fastify, { type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { - ALIGN_QUEUE_NAME, - PDF_LAYOUT_QUEUE_NAME, + connect, + nanos, + type NatsConnection, +} from '@nats-io/transport-node'; +import { + AckPolicy, + DeliverPolicy, + ReplayPolicy, + RetentionPolicy, + StorageType, + jetstream, + jetstreamManager, + type Consumer, + type JetStreamClient, + type JetStreamManager, + type JsMsg, +} from '@nats-io/jetstream'; +import { Kvm, type KV } from '@nats-io/kv'; +import { ensureComputeModels, runPdfLayoutFromPdfBuffer, runWhisperAlignmentFromAudioBuffer, @@ -17,6 +32,33 @@ import { } from '@openreader/compute-core'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +const JOBS_STREAM_NAME = 'compute_jobs'; +const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; +const LAYOUT_JOBS_SUBJECT = 'jobs.layout'; +const WHISPER_CONSUMER_NAME = 'compute_whisper'; +const LAYOUT_CONSUMER_NAME = 'compute_layout'; +const JOB_STATES_BUCKET = 'job_states'; +const JOB_STATES_TTL_MS = 24 * 60 * 60 * 1000; +const PULL_EXPIRES_MS = 1000; +const LOOP_ERROR_BACKOFF_MS = 500; + +interface QueuedJob { + jobId: string; + queuedAt: number; + payload: TPayload; +} + +interface StoredJobState extends WorkerJobStatusResponse { + timestamp: number; + startedAt?: number; + updatedAt: number; +} + +type JsonCodec = { + encode(value: T): Uint8Array; + decode(data: Uint8Array): T; +}; + function requireEnv(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required`); @@ -126,12 +168,6 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function parseRetryAfterSeconds(raw: string | undefined): number { - const parsed = Number(raw ?? ''); - if (!Number.isFinite(parsed) || parsed <= 0) return 2; - return Math.max(1, Math.floor(parsed)); -} - function isAuthed(request: FastifyRequest, expectedToken: string): boolean { const auth = request.headers.authorization; if (!auth?.startsWith('Bearer ')) return false; @@ -139,6 +175,34 @@ function isAuthed(request: FastifyRequest, expectedToken: string): boolean { return token === expectedToken; } +function safeDurationMs(start: number | undefined, end: number | undefined): number | undefined { + if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined; + return Math.max(0, Math.floor((end as number) - (start as number))); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function isAlreadyExistsError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return message.includes('already in use') || message.includes('already exists'); +} + +function createJsonCodec(): JsonCodec { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + return { + encode(value: T): Uint8Array { + return encoder.encode(JSON.stringify(value)); + }, + decode(data: Uint8Array): T { + return JSON.parse(decoder.decode(data)) as T; + }, + }; +} + const alignSchema = z.object({ text: z.string().trim().min(1), lang: z.string().trim().min(1).max(16).optional(), @@ -152,83 +216,194 @@ const layoutSchema = z.object({ documentObjectKey: z.string().trim().min(1).max(2048), }); -function mapJobState(job: Job): WorkerJobStatusResponse { - const result = job.returnvalue as Result | undefined; - const resultTiming = readResultTiming(result); - const timing = buildJobTimingSnapshot(job, resultTiming); - - if (job.failedReason) { - return { - status: 'failed', - error: { - message: job.failedReason || 'Worker job failed', - }, - ...(timing ? { timing } : {}), - }; - } - if (typeof job.returnvalue !== 'undefined' && job.finishedOn) { - return { - status: 'succeeded', - result: job.returnvalue as Result, - ...(timing ? { timing } : {}), - }; - } - - if (job.processedOn) { - return { - status: 'running', - ...(timing ? { timing } : {}), - }; - } - return { - status: 'queued', - ...(timing ? { timing } : {}), - }; +async function putState( + kv: KV, + codec: JsonCodec>, + jobId: string, + state: StoredJobState, +): Promise { + await kv.put(jobId, codec.encode(state)); } -function readResultTiming(result: Result | undefined): WorkerJobTiming | undefined { - if (!result || typeof result !== 'object') return undefined; - const maybe = result as { timing?: WorkerJobTiming }; - if (!maybe.timing || typeof maybe.timing !== 'object') return undefined; - return maybe.timing; +async function getState( + kv: KV, + codec: JsonCodec>, + jobId: string, +): Promise | null> { + const entry = await kv.get(jobId); + if (!entry || entry.operation !== 'PUT') return null; + return codec.decode(entry.value); } -function toSafeDurationMs(start: number | undefined, end: number | undefined): number | undefined { - if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined; - return Math.max(0, Math.floor((end as number) - (start as number))); -} - -function buildJobTimingSnapshot(job: Job, base?: WorkerJobTiming): WorkerJobTiming | undefined { - const now = Date.now(); - const timestamp = typeof job.timestamp === 'number' ? job.timestamp : undefined; - const processedOn = typeof job.processedOn === 'number' ? job.processedOn : undefined; - - const timing: WorkerJobTiming = { - ...(base ?? {}), +async function ensureJetStreamResources( + jsm: JetStreamManager, + whisperTimeoutMs: number, + pdfTimeoutMs: number, + attempts: number, +): Promise { + const streamConfig = { + name: JOBS_STREAM_NAME, + subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], + retention: RetentionPolicy.Workqueue, + storage: StorageType.File, }; - if (typeof timing.queueWaitMs !== 'number') { - timing.queueWaitMs = processedOn - ? toSafeDurationMs(timestamp, processedOn) - : toSafeDurationMs(timestamp, now); + try { + await jsm.streams.add(streamConfig); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.streams.update(JOBS_STREAM_NAME, { + subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], + }); } - const hasAnyTiming = Object.values(timing).some((value) => typeof value === 'number' && Number.isFinite(value)); - return hasAnyTiming ? timing : undefined; + const ensureConsumer = async (name: string, subject: string, ackWaitMs: number): Promise => { + const config = { + durable_name: name, + ack_policy: AckPolicy.Explicit, + deliver_policy: DeliverPolicy.All, + replay_policy: ReplayPolicy.Instant, + filter_subject: subject, + ack_wait: nanos(Math.max(ackWaitMs, 1_000)), + max_deliver: attempts, + }; + + try { + await jsm.consumers.add(JOBS_STREAM_NAME, config); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.consumers.update(JOBS_STREAM_NAME, name, { + filter_subject: subject, + ack_wait: nanos(Math.max(ackWaitMs, 1_000)), + max_deliver: attempts, + }); + } + }; + + await Promise.all([ + ensureConsumer(WHISPER_CONSUMER_NAME, WHISPER_JOBS_SUBJECT, whisperTimeoutMs + 15_000), + ensureConsumer(LAYOUT_CONSUMER_NAME, LAYOUT_JOBS_SUBJECT, pdfTimeoutMs + 15_000), + ]); } -async function getQueueDepth(queue: Queue): Promise { - const counts = await queue.getJobCounts('waiting', 'active', 'prioritized', 'delayed'); - return (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.prioritized ?? 0) + (counts.delayed ?? 0); +async function createWorkerLoop(input: { + consumer: Consumer; + kv: KV; + stateCodec: JsonCodec>; + jobCodec: JsonCodec>; + run: (payload: TPayload, queueWaitMs: number) => Promise; + maxAttempts: number; + logLabel: string; + shouldStop: () => boolean; + log: { + error: (obj: Record, msg: string) => void; + info: (obj: Record, msg: string) => void; + }; +}): Promise { + while (!input.shouldStop()) { + let msg: JsMsg | null = null; + try { + msg = await input.consumer.next({ expires: PULL_EXPIRES_MS }); + } catch (error) { + if (input.shouldStop()) return; + input.log.error({ error: toErrorMessage(error), worker: input.logLabel }, 'worker pull failed'); + await new Promise((resolve) => setTimeout(resolve, LOOP_ERROR_BACKOFF_MS)); + continue; + } + + if (!msg) continue; + + let decoded: QueuedJob | null = null; + try { + const job = input.jobCodec.decode(msg.data); + decoded = job; + const startedAt = Date.now(); + const queueWaitMs = safeDurationMs(job.queuedAt, startedAt); + + await putState(input.kv, input.stateCodec, job.jobId, { + status: 'running', + timestamp: job.queuedAt, + startedAt, + updatedAt: startedAt, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }); + + const result = await input.run(job.payload, queueWaitMs ?? 0); + const resultTiming = result && typeof result === 'object' && 'timing' in result + ? (result as { timing?: WorkerJobTiming }).timing + : undefined; + + await putState(input.kv, input.stateCodec, job.jobId, { + status: 'succeeded', + timestamp: job.queuedAt, + startedAt, + updatedAt: Date.now(), + result, + ...(resultTiming ? { timing: resultTiming } : {}), + }); + + msg.ack(); + input.log.info({ worker: input.logLabel, jobId: job.jobId, timing: resultTiming }, 'job succeeded'); + } catch (error) { + const message = toErrorMessage(error); + const deliveryCount = msg.info.deliveryCount; + const hasRetriesLeft = deliveryCount < input.maxAttempts; + + if (decoded?.jobId) { + const now = Date.now(); + const queueWaitMs = safeDurationMs(decoded.queuedAt, now); + const state: StoredJobState = hasRetriesLeft + ? { + status: 'running', + timestamp: decoded.queuedAt, + updatedAt: now, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + } + : { + status: 'failed', + timestamp: decoded.queuedAt, + updatedAt: now, + error: { message }, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }; + + await putState(input.kv, input.stateCodec, decoded.jobId, state).catch((stateError) => { + input.log.error({ + worker: input.logLabel, + jobId: decoded?.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist failed state'); + }); + } + + if (hasRetriesLeft) { + msg.nak(); + input.log.error({ + worker: input.logLabel, + jobId: decoded?.jobId, + error: message, + deliveryCount, + maxAttempts: input.maxAttempts, + }, 'job failed, nacked for retry'); + } else { + msg.term(message); + input.log.error({ + worker: input.logLabel, + jobId: decoded?.jobId, + error: message, + deliveryCount, + maxAttempts: input.maxAttempts, + }, 'job failed, max attempts reached'); + } + } + } } async function main(): Promise { const port = readIntEnv('COMPUTE_WORKER_PORT', 8081); const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; const workerToken = requireEnv('COMPUTE_WORKER_TOKEN'); - const redisUrl = requireEnv('REDIS_URL'); - const queueMaxDepth = readIntEnv('COMPUTE_QUEUE_MAX_DEPTH', 64); - const retryAfterSec = parseRetryAfterSeconds(process.env.COMPUTE_QUEUE_RETRY_AFTER_SEC); + const natsUrl = requireEnv('NATS_URL'); const whisperConcurrency = readIntEnv('COMPUTE_WHISPER_CONCURRENCY', 1); const pdfConcurrency = readIntEnv('COMPUTE_PDF_CONCURRENCY', 2); @@ -237,34 +412,15 @@ async function main(): Promise { const attempts = readIntEnv('COMPUTE_JOB_ATTEMPTS', 2); const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); - const redis = new IORedis(redisUrl, { - maxRetriesPerRequest: null, - enableReadyCheck: true, - }); + const nc: NatsConnection = await connect({ servers: natsUrl }); + const js: JetStreamClient = jetstream(nc); + const jsm: JetStreamManager = await jetstreamManager(nc); - const queueDefaults: JobsOptions = { - attempts, - backoff: { - type: 'exponential', - delay: 500, - }, - removeOnComplete: { - age: 60 * 60, - count: 1000, - }, - removeOnFail: { - age: 24 * 60 * 60, - count: 5000, - }, - }; + await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, attempts); - const alignQueue = new Queue(ALIGN_QUEUE_NAME, { - connection: redis, - defaultJobOptions: queueDefaults, - }); - const layoutQueue = new Queue(PDF_LAYOUT_QUEUE_NAME, { - connection: redis, - defaultJobOptions: queueDefaults, + const kv = await new Kvm(js).create(JOB_STATES_BUCKET, { + history: 1, + ttl: JOB_STATES_TTL_MS, }); const s3 = buildS3Client(); @@ -289,123 +445,16 @@ async function main(): Promise { return toArrayBuffer(new Uint8Array(bytes)); }; - const alignWorker = new Worker( - ALIGN_QUEUE_NAME, - async (job) => { - const processingStartedAt = Date.now(); - const queueWaitMs = typeof job.timestamp === 'number' - ? Math.max(0, processingStartedAt - job.timestamp) - : undefined; - const parsed = alignSchema.parse(job.data); - const s3FetchStartedAt = Date.now(); - const audioBuffer = await readObjectByKey(parsed.audioObjectKey); - const s3FetchMs = Date.now() - s3FetchStartedAt; - const computeStartedAt = Date.now(); - const result = await withTimeout( - runWhisperAlignmentFromAudioBuffer({ - audioBuffer, - text: parsed.text, - cacheKey: parsed.cacheKey, - lang: parsed.lang, - }), - whisperTimeoutMs, - 'whisper alignment job', - ); - const computeMs = Date.now() - computeStartedAt; - return { - ...result, - timing: { - ...(result.timing ?? {}), - queueWaitMs, - s3FetchMs, - computeMs, - }, - }; - }, - { - connection: redis, - concurrency: whisperConcurrency, - }, - ); - - const layoutWorker = new Worker( - PDF_LAYOUT_QUEUE_NAME, - async (job) => { - const processingStartedAt = Date.now(); - const queueWaitMs = typeof job.timestamp === 'number' - ? Math.max(0, processingStartedAt - job.timestamp) - : undefined; - const parsed = layoutSchema.parse(job.data); - const s3FetchStartedAt = Date.now(); - const pdfBytes = await readObjectByKey(parsed.documentObjectKey); - const s3FetchMs = Date.now() - s3FetchStartedAt; - const computeStartedAt = Date.now(); - const result = await withTimeout( - runPdfLayoutFromPdfBuffer({ - documentId: parsed.documentId, - pdfBytes, - }), - pdfTimeoutMs, - 'pdf layout job', - ); - const computeMs = Date.now() - computeStartedAt; - return { - ...result, - timing: { - ...(result.timing ?? {}), - queueWaitMs, - s3FetchMs, - computeMs, - }, - }; - }, - { - connection: redis, - concurrency: pdfConcurrency, - }, - ); - if (prewarmModels) { await ensureComputeModels(); } - const app = Fastify({ - logger: buildLoggerConfig(), - }); + const app = Fastify({ logger: buildLoggerConfig() }); - alignWorker.on('completed', (job, result) => { - app.log.info({ - queue: ALIGN_QUEUE_NAME, - jobId: job.id, - timing: buildJobTimingSnapshot(job, readResultTiming(result)), - }, 'whisper align job completed'); - }); - - alignWorker.on('failed', (job, err) => { - app.log.error({ - queue: ALIGN_QUEUE_NAME, - jobId: job?.id, - error: err.message, - timing: job ? buildJobTimingSnapshot(job) : undefined, - }, 'whisper align job failed'); - }); - - layoutWorker.on('completed', (job, result) => { - app.log.info({ - queue: PDF_LAYOUT_QUEUE_NAME, - jobId: job.id, - timing: buildJobTimingSnapshot(job, readResultTiming(result)), - }, 'pdf layout job completed'); - }); - - layoutWorker.on('failed', (job, err) => { - app.log.error({ - queue: PDF_LAYOUT_QUEUE_NAME, - jobId: job?.id, - error: err.message, - timing: job ? buildJobTimingSnapshot(job) : undefined, - }, 'pdf layout job failed'); - }); + const whisperStateCodec = createJsonCodec>(); + const layoutStateCodec = createJsonCodec>(); + const whisperJobCodec = createJsonCodec>(); + const layoutJobCodec = createJsonCodec>(); app.addHook('onRequest', async (request, reply) => { const path = request.url.split('?')[0] ?? request.url; @@ -420,30 +469,17 @@ async function main(): Promise { app.get('/health/ready', async (_request, reply) => { try { - await redis.ping(); + await nc.flush(); return { ok: true }; } catch (error) { reply.code(503); return { ok: false, - error: error instanceof Error ? error.message : String(error), + error: toErrorMessage(error), }; } }); - const rejectIfSaturated = async (queue: Queue, reply: FastifyReply): Promise => { - const depth = await getQueueDepth(queue); - if (depth < queueMaxDepth) return false; - reply.header('Retry-After', String(retryAfterSec)); - reply.code(429).send({ - error: 'Queue is saturated', - retryAfterSeconds: retryAfterSec, - queueDepth: depth, - queueMaxDepth, - }); - return true; - }; - app.post('/align/whisper/jobs', async (request, reply) => { const parsed = alignSchema.safeParse(request.body); if (!parsed.success) { @@ -453,11 +489,24 @@ async function main(): Promise { issues: parsed.error.issues, }; } - if (await rejectIfSaturated(alignQueue, reply)) return; - const job = await alignQueue.add('align', parsed.data); + const jobId = crypto.randomUUID(); + const queuedAt = Date.now(); + + await putState(kv, whisperStateCodec, jobId, { + status: 'queued', + timestamp: queuedAt, + updatedAt: queuedAt, + }); + + await js.publish(WHISPER_JOBS_SUBJECT, whisperJobCodec.encode({ + jobId, + queuedAt, + payload: parsed.data, + })); + reply.code(202); - return { jobId: String(job.id) }; + return { jobId }; }); app.get('/align/whisper/jobs/:jobId', async (request, reply) => { @@ -466,12 +515,21 @@ async function main(): Promise { reply.code(400); return { error: 'Invalid job id' }; } - const job = await alignQueue.getJob(params.data.jobId); - if (!job) { + + const state = await getState(kv, whisperStateCodec, params.data.jobId); + if (!state) { reply.code(404); return { error: 'Job not found' }; } - return mapJobState(job); + + const response: WorkerJobStatusResponse = { + status: state.status, + ...(state.result ? { result: state.result } : {}), + ...(state.error ? { error: state.error } : {}), + ...(state.timing ? { timing: state.timing } : {}), + }; + + return response; }); app.post('/layout/pdf/jobs', async (request, reply) => { @@ -483,11 +541,24 @@ async function main(): Promise { issues: parsed.error.issues, }; } - if (await rejectIfSaturated(layoutQueue, reply)) return; - const job = await layoutQueue.add('layout', parsed.data); + const jobId = crypto.randomUUID(); + const queuedAt = Date.now(); + + await putState(kv, layoutStateCodec, jobId, { + status: 'queued', + timestamp: queuedAt, + updatedAt: queuedAt, + }); + + await js.publish(LAYOUT_JOBS_SUBJECT, layoutJobCodec.encode({ + jobId, + queuedAt, + payload: parsed.data, + })); + reply.code(202); - return { jobId: String(job.id) }; + return { jobId }; }); app.get('/layout/pdf/jobs/:jobId', async (request, reply) => { @@ -496,28 +567,136 @@ async function main(): Promise { reply.code(400); return { error: 'Invalid job id' }; } - const job = await layoutQueue.getJob(params.data.jobId); - if (!job) { + + const state = await getState(kv, layoutStateCodec, params.data.jobId); + if (!state) { reply.code(404); return { error: 'Job not found' }; } - return mapJobState(job); + + const response: WorkerJobStatusResponse = { + status: state.status, + ...(state.result ? { result: state.result } : {}), + ...(state.error ? { error: state.error } : {}), + ...(state.timing ? { timing: state.timing } : {}), + }; + + return response; }); + const whisperConsumer = await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME); + const layoutConsumer = await js.consumers.get(JOBS_STREAM_NAME, LAYOUT_CONSUMER_NAME); + + let stopping = false; + + const runWhisper = async ( + payload: WhisperAlignJobRequest, + queueWaitMs: number, + ): Promise => { + const parsed = alignSchema.parse(payload); + + const s3FetchStartedAt = Date.now(); + const audioBuffer = await readObjectByKey(parsed.audioObjectKey); + const s3FetchMs = Date.now() - s3FetchStartedAt; + + const computeStartedAt = Date.now(); + const result = await withTimeout( + runWhisperAlignmentFromAudioBuffer({ + audioBuffer, + text: parsed.text, + cacheKey: parsed.cacheKey, + lang: parsed.lang, + }), + whisperTimeoutMs, + 'whisper alignment job', + ); + + const computeMs = Date.now() - computeStartedAt; + return { + ...result, + timing: { + ...(result.timing ?? {}), + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; + }; + + const runLayout = async ( + payload: PdfLayoutJobRequest, + queueWaitMs: number, + ): Promise => { + const parsed = layoutSchema.parse(payload); + + const s3FetchStartedAt = Date.now(); + const pdfBytes = await readObjectByKey(parsed.documentObjectKey); + const s3FetchMs = Date.now() - s3FetchStartedAt; + + const computeStartedAt = Date.now(); + const result = await withTimeout( + runPdfLayoutFromPdfBuffer({ + documentId: parsed.documentId, + pdfBytes, + }), + pdfTimeoutMs, + 'pdf layout job', + ); + + const computeMs = Date.now() - computeStartedAt; + return { + ...result, + timing: { + ...(result.timing ?? {}), + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; + }; + + const workerLoops: Promise[] = []; + + for (let i = 0; i < whisperConcurrency; i += 1) { + workerLoops.push(createWorkerLoop({ + consumer: whisperConsumer, + kv, + stateCodec: whisperStateCodec, + jobCodec: whisperJobCodec, + run: runWhisper, + maxAttempts: attempts, + logLabel: `whisper-${i + 1}`, + shouldStop: () => stopping, + log: app.log, + })); + } + + for (let i = 0; i < pdfConcurrency; i += 1) { + workerLoops.push(createWorkerLoop({ + consumer: layoutConsumer, + kv, + stateCodec: layoutStateCodec, + jobCodec: layoutJobCodec, + run: runLayout, + maxAttempts: attempts, + logLabel: `layout-${i + 1}`, + shouldStop: () => stopping, + log: app.log, + })); + } + const close = async (): Promise => { + if (stopping) return; + stopping = true; await app.close(); - await Promise.allSettled([ - alignWorker.close(), - layoutWorker.close(), - alignQueue.close(), - layoutQueue.close(), - redis.quit(), - ]); + await Promise.allSettled(workerLoops); + await nc.drain(); }; process.once('SIGINT', () => { void close().finally(() => process.exit(0)); }); + process.once('SIGTERM', () => { void close().finally(() => process.exit(0)); }); diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index af00ab2..f57ce9a 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -1,5 +1,4 @@ ---- -title: Compute Worker (Redis + BullMQ) +title: Compute Worker (NATS JetStream) --- Use this guide for `COMPUTE_MODE=worker` deployments where heavy compute runs outside the Next.js app server. @@ -11,7 +10,7 @@ The compute worker handles: - Whisper word alignment (`/align/whisper/jobs`) - PDF layout parsing (`/layout/pdf/jobs`) -The app server enqueues jobs and polls status. Queue durability and retries are backed by Redis + BullMQ. +The app server enqueues jobs and polls status. Queue durability and retries are backed by NATS JetStream WorkQueue consumers and NATS KV. ## Published image @@ -23,7 +22,7 @@ The app server enqueues jobs and polls status. Queue durability and retries are Required: - `COMPUTE_WORKER_TOKEN`: bearer token expected by worker routes -- `REDIS_URL`: BullMQ Redis connection string +- `NATS_URL`: NATS server connection string (JetStream enabled) - `S3_BUCKET` - `S3_REGION` - `S3_ACCESS_KEY_ID` @@ -37,7 +36,6 @@ Common optional: - `COMPUTE_WORKER_HOST=0.0.0.0` - `COMPUTE_WORKER_PORT=8081` - `COMPUTE_LOG_FORMAT=pretty` (default) or `json` -- `COMPUTE_QUEUE_MAX_DEPTH=64` - `COMPUTE_PREWARM_MODELS=true` ## App server environment variables (worker mode) diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 80c07c7..22703cf 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -127,10 +127,10 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i
External compute worker dev stack (optional) -Use this when you want durable compute with Redis/BullMQ while keeping Next.js on native host `pnpm dev`. -Full worker deployment details are in [Compute Worker (Redis + BullMQ)](./compute-worker). +Use this when you want durable compute with NATS JetStream + KV while keeping Next.js on native host `pnpm dev`. +Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker). -Start only Redis + compute-worker via compose watch: +Start only NATS + compute-worker via compose watch: ```bash docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch @@ -243,7 +243,7 @@ S3_SECRET_ACCESS_KEY=your-secret-key ``` - + ```env API_BASE=http://host.docker.internal:8880/v1 diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 3c1502b..7fa3842 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -9,7 +9,7 @@ This guide covers deploying OpenReader to Vercel with external Postgres and S3-c - Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. - Audiobook routes work on Node.js serverless functions using `ffmpeg-static`. - Heavy compute features (Whisper alignment + PDF layout parsing) work through `COMPUTE_MODE=worker` with an external compute worker service. -- For worker setup details and worker-specific env vars, see [Compute Worker (Redis + BullMQ)](./compute-worker). +- For worker setup details and worker-specific env vars, see [Compute Worker (NATS JetStream)](./compute-worker). :::warning DOCX Conversion Limitation `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 97a555a..f61ea07 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -141,7 +141,7 @@ Visit [http://localhost:3003](http://localhost:3003) after startup. ## 3. Update Docker image Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias. -For external compute mode image details, see [Compute Worker (Redis + BullMQ)](./deploy/compute-worker). +For external compute mode image details, see [Compute Worker (NATS JetStream)](./deploy/compute-worker). ```bash docker stop openreader || true && \ diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index d8c3c67..4759768 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -15,7 +15,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - 🧱 **Layout-aware PDF Parsing** - PP-DocLayoutV3 (ONNX) detects structured blocks with cross-page stitching and geometry-based highlighting for precise read-along sync and clean TTS segmentation - ⏱️ **Word-by-word Highlighting** via ONNX Whisper alignment - - No external dependencies — runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable Redis-backed worker (`COMPUTE_MODE=worker`) + - No external dependencies — runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable NATS JetStream-backed worker (`COMPUTE_MODE=worker`) - ⚡ **Segment-based TTS Playback** - Sentence-aware generation with cached audio segments, background preloading, and resumable playback across EPUB, PDF, TXT, MD, and DOCX - 🎯 **Multi-Provider TTS Support** diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 207b30a..efae35e 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -357,11 +357,11 @@ Selects the backend for heavy compute features (ONNX word alignment + PDF layout - Default: `local` - Supported in v1: - `local`: run compute in-process on the app server - - `worker`: enqueue async jobs in an external durable compute worker (Redis + BullMQ) + - `worker`: enqueue async jobs in an external durable compute worker (NATS JetStream + NATS KV) - `worker` requires `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` - `worker` assumes the external worker can directly reach shared object storage (S3-compatible endpoint) - `worker` is not compatible with non-exposed embedded `weed mini` storage topologies -- Worker service env vars are documented in [Compute Worker (Redis + BullMQ)](../deploy/compute-worker) +- Worker service env vars are documented in [Compute Worker (NATS JetStream)](../deploy/compute-worker) ### COMPUTE_WORKER_URL diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 7f4061a..5d1d538 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -55,11 +55,11 @@ Monorepo packages under `compute/`: - Utilities: `jszip`, `ffmpeg-static` - **`@openreader/compute-worker`** — standalone Node.js worker service - HTTP server: [Fastify](https://fastify.dev/) v5 - - Job queue: [BullMQ](https://bullmq.io/) + [ioredis](https://github.com/redis/ioredis) (queues: `whisper-align`, `pdf-layout`) + - Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`) - Storage: AWS SDK v3 S3 client for reading/writing blobs - Logging: [Pino](https://getpino.io/) - Validation: [Zod](https://zod.dev/) -- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process) or `worker` (remote queue via HTTP + Redis) +- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process) or `worker` (remote queue via HTTP + NATS) ## Tooling and testing diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49947fd..7303f48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,7 +148,7 @@ importers: version: 1.60.0 '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.22.3)) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -181,7 +181,7 @@ importers: version: 8.5.14 tailwindcss: specifier: ^3.4.19 - version: 3.4.19(tsx@4.21.0) + version: 3.4.19(tsx@4.22.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -210,23 +210,26 @@ importers: compute/worker: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1045.0 - version: 3.1045.0 + specifier: ^3.1050.0 + version: 3.1050.0 + '@nats-io/jetstream': + specifier: ^3.4.0 + version: 3.4.0 + '@nats-io/kv': + specifier: ^3.4.0 + version: 3.4.0 + '@nats-io/transport-node': + specifier: ^3.4.0 + version: 3.4.0 '@openreader/compute-core': specifier: workspace:* version: link:../core - bullmq: - specifier: ^5.61.2 - version: 5.76.10 fastify: specifier: ^5.6.2 version: 5.8.5 - ioredis: - specifier: ^5.8.2 - version: 5.10.1 pino: - specifier: ^9.14.0 - version: 9.14.0 + specifier: ^10.3.1 + version: 10.3.1 pino-pretty: specifier: ^13.1.2 version: 13.1.3 @@ -235,8 +238,8 @@ importers: version: 4.4.3 devDependencies: tsx: - specifier: ^4.20.6 - version: 4.21.0 + specifier: ^4.22.3 + version: 4.22.3 packages: @@ -271,6 +274,14 @@ packages: resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1050.0': + resolution: {integrity: sha512-9kgtv+bXZQrOIJT2INPPBCezrJu1FlgGrzEat/ut4A4V53IT00LynsBZgp12eFKbjJuNCeTo7iPSKjPsX8ub+A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.12': + resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.8': resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} engines: {node: '>=20.0.0'} @@ -279,50 +290,98 @@ packages: resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.8': + resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.34': resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.38': + resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.36': resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.40': + resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.38': resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.42': + resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.38': resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.42': + resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.39': resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.43': + resolution: {integrity: sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.34': resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.38': + resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.38': resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.42': + resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.38': resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.42': + resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.10': resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + resolution: {integrity: sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.10': resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.12': + resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.16': resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.20': + resolution: {integrity: sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.10': resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} engines: {node: '>=20.0.0'} @@ -343,6 +402,10 @@ packages: resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.41': + resolution: {integrity: sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-ssec@3.972.10': resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} @@ -351,6 +414,10 @@ packages: resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.10': + resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.6': resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} engines: {node: '>=20.0.0'} @@ -367,10 +434,18 @@ packages: resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.27': + resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1041.0': resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1049.0': + resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -407,6 +482,10 @@ packages: resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.24': + resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -530,6 +609,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -548,6 +633,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -566,6 +657,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -584,6 +681,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -602,6 +705,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -620,6 +729,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -638,6 +753,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -656,6 +777,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -674,6 +801,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -692,6 +825,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -710,6 +849,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -728,6 +873,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -746,6 +897,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -764,6 +921,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -782,6 +945,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -800,6 +969,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -818,6 +993,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -830,6 +1011,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -848,6 +1035,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -860,6 +1053,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -878,6 +1077,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -890,6 +1095,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -908,6 +1119,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -926,6 +1143,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -944,6 +1167,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -962,6 +1191,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1231,9 +1466,6 @@ packages: '@internationalized/string@3.2.8': resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==} - '@ioredis/commands@1.5.1': - resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1251,36 +1483,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} - cpu: [x64] - os: [win32] - '@napi-rs/canvas-android-arm64@0.1.100': resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} @@ -1359,6 +1561,27 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nats-io/jetstream@3.4.0': + resolution: {integrity: sha512-GzHQodNJ942+R5LRb8PuZ5ugVWVWMRiufxUYLLVWkXKfwDXYN+Owo0d7L/b9O7BPyrbYD7jQWAC6+ZVuXa9Gyw==} + + '@nats-io/kv@3.4.0': + resolution: {integrity: sha512-168pcRJxWcIRHwdyczI3DaGk5r3CAj3CyKXuk4Nmx5KKj7N1znQOJPIxCoV0TPlAvoTidVb7eBv41J9imapMeQ==} + + '@nats-io/nats-core@3.4.0': + resolution: {integrity: sha512-QMDM86EUNm+wudlKC67HLar/KHHQUvJGLfb4jbahje3pUk3K9afeck9fwsxBZF0eUFox6T/TA6m/t+lQqf+QsQ==} + + '@nats-io/nkeys@2.0.3': + resolution: {integrity: sha512-JVt56GuE6Z89KUkI4TXUbSI9fmIfAmk6PMPknijmuL72GcD+UgIomTcRWiNvvJKxA01sBbmIPStqJs5cMRBC3A==} + engines: {node: '>=18.0.0'} + + '@nats-io/nuid@3.0.0': + resolution: {integrity: sha512-QbXZDrxmYlrn5AD06gYcUTmEHwxn96HBQMIk7XTjDlEcx6FPzVBBPjp4AMRh1lEv6B4iJ6Xb/Uz61JugPXFRQg==} + engines: {node: '>= 22.x'} + + '@nats-io/transport-node@3.4.0': + resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} + engines: {node: '>= 18.0.0'} + '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} @@ -1501,10 +1724,18 @@ packages: engines: {node: '>=18.0.0'} deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 + '@smithy/core@3.24.3': + resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.1': resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.3': + resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.3.1': resolution: {integrity: sha512-X7MyI1fu8M84IPKk49kO4kb27Mqp6un9/0o/MsA1ngZ5OxxWKGUxPS3S/AJ9q1cPVTSGmRcbaGNfGUSsflTJkg==} engines: {node: '>=18.0.0'} @@ -1521,6 +1752,10 @@ packages: resolution: {integrity: sha512-r7bN6spQ+caZC8AnyvSxkRUb57zt2jhhRw3Z+2Ez8hjq6coIikDBFUUI/+CQ1xx9K6eX1Gx6wUKo4ylU66TIqw==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.3': + resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==} + engines: {node: '>=18.0.0'} + '@smithy/hash-blob-browser@4.3.1': resolution: {integrity: sha512-2fbltQVQYmGd0OzPv2oDMRF0pxkzeIx8cbpx2x6W3UJWGaEyUzVPxF4d0sDXZ/r2obg+RbTyhTidXWlPDsKRKw==} engines: {node: '>=18.0.0'} @@ -1577,6 +1812,10 @@ packages: resolution: {integrity: sha512-BdEYko85f/ldp68uH8XEyIvo810xFk6eyPH81SRggTOApYHWA+Xu7B2EzLuHbe37WVLaUA7F1fWR3/zBeme2WA==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.3.1': resolution: {integrity: sha512-3NHoqVBhzpY2b4YBx9AqyKC4C8nnEjl5FyKuxrCjvnjinG0ODj+yg1xX360nNahT6wghYjSw1SooCt3kIdnqIA==} engines: {node: '>=18.0.0'} @@ -1597,6 +1836,10 @@ packages: resolution: {integrity: sha512-728lZZEWYWubBESrfntNslZQYDKRlJDY4dcDnYbL50+gu35pGPLblu4S0/RH/RDLF6me1M87ECHsHELGL7dA/Q==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.3': + resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.13.1': resolution: {integrity: sha512-IcznNM8Qd9u1X3oflp12tkzyOB4HbT+sfYWlWiyEysgNzSHoWcHUUsTT4y1jjDjtVuuVVQbYks+g1kVd7u1eGQ==} engines: {node: '>=18.0.0'} @@ -1605,6 +1848,10 @@ packages: resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.3.1': resolution: {integrity: sha512-tuelFlF2PZR/wogFC58NIrPOv+Zna4N1+3kA161/33D1Gbwvl6Nh4WsAsW05ZyPp0O6CMGsdbb0S2b/qVjRMCw==} engines: {node: '>=18.0.0'} @@ -2281,10 +2528,6 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bullmq@5.76.10: - resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==} - engines: {node: '>=12.22.0'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2348,10 +2591,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - cmpstr@3.3.0: resolution: {integrity: sha512-of06NSLK24YPV89qYmlJQxENZF43BtIGlXmrFaUIdkuBMayIckFX9NjRB6PPfkhxpLIOb6LhiTe/P5QoBu6r3w==} engines: {node: '>=18.0.0'} @@ -2407,10 +2646,6 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} - cron-parser@4.9.0: - resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} - engines: {node: '>=12.0.0'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2487,10 +2722,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2712,6 +2943,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2916,6 +3152,10 @@ packages: resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} hasBin: true + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fast-xml-parser@5.8.0: resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true @@ -3150,10 +3390,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ioredis@5.10.1: - resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} - engines: {node: '>=12.22.0'} - ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -3394,12 +3630,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - - lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3420,10 +3650,6 @@ packages: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} - luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} - engines: {node: '>=12'} - make-cancellable-promise@1.3.2: resolution: {integrity: sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==} @@ -3621,13 +3847,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.3: - resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} - hasBin: true - - msgpackr@2.0.1: - resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} - mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3679,9 +3898,6 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -3689,10 +3905,6 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3883,6 +4095,10 @@ packages: pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pino@9.14.0: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true @@ -4129,13 +4345,8 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -4330,9 +4541,6 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4476,6 +4684,10 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4517,9 +4729,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4776,6 +4996,38 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-s3@3.1050.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-node': 3.972.43 + '@aws-sdk/middleware-bucket-endpoint': 3.972.14 + '@aws-sdk/middleware-expect-continue': 3.972.12 + '@aws-sdk/middleware-flexible-checksums': 3.974.20 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-sdk-s3': 3.972.41 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.24 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.8': dependencies: '@aws-sdk/types': 3.973.8 @@ -4798,6 +5050,11 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.34': dependencies: '@aws-sdk/core': 3.974.8 @@ -4806,6 +5063,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.36': dependencies: '@aws-sdk/core': 3.974.8 @@ -4819,6 +5084,16 @@ snapshots: '@smithy/util-stream': 4.6.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4838,6 +5113,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-login': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4851,6 +5142,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.39': dependencies: '@aws-sdk/credential-provider-env': 3.972.34 @@ -4868,6 +5168,20 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.43': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-ini': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.34': dependencies: '@aws-sdk/core': 3.974.8 @@ -4877,6 +5191,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4890,6 +5212,16 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/token-providers': 3.1049.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4902,6 +5234,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4912,6 +5253,14 @@ snapshots: '@smithy/util-config-provider': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4919,6 +5268,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.16': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -4936,6 +5292,18 @@ snapshots: '@smithy/util-utf8': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.20': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/crc64-nvme': 3.972.8 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4980,6 +5348,16 @@ snapshots: '@smithy/util-utf8': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-ssec@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4997,6 +5375,19 @@ snapshots: '@smithy/util-retry': 4.4.1 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.10': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.6': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5069,6 +5460,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.27': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1041.0': dependencies: '@aws-sdk/core': 3.974.8 @@ -5081,6 +5480,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1049.0': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -5132,6 +5540,13 @@ snapshots: fast-xml-parser: 5.7.2 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.24': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/runtime@7.29.2': {} @@ -5230,6 +5645,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -5239,6 +5657,9 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -5248,6 +5669,9 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -5257,6 +5681,9 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -5266,6 +5693,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -5275,6 +5705,9 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -5284,6 +5717,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -5293,6 +5729,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -5302,6 +5741,9 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -5311,6 +5753,9 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -5320,6 +5765,9 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -5329,6 +5777,9 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -5338,6 +5789,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -5347,6 +5801,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -5356,6 +5813,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -5365,6 +5825,9 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -5374,12 +5837,18 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -5389,12 +5858,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -5404,12 +5879,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -5419,6 +5900,9 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -5428,6 +5912,9 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -5437,6 +5924,9 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -5446,6 +5936,9 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': dependencies: eslint: 9.39.4(jiti@1.21.7) @@ -5677,8 +6170,6 @@ snapshots: dependencies: '@swc/helpers': 0.5.21 - '@ioredis/commands@1.5.1': {} - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5702,24 +6193,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': - optional: true - '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -5774,6 +6247,32 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nats-io/jetstream@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + + '@nats-io/kv@3.4.0': + dependencies: + '@nats-io/jetstream': 3.4.0 + '@nats-io/nats-core': 3.4.0 + + '@nats-io/nats-core@3.4.0': + dependencies: + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + + '@nats-io/nkeys@2.0.3': + dependencies: + tweetnacl: 1.0.3 + + '@nats-io/nuid@3.0.0': {} + + '@nats-io/transport-node@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + '@next/env@15.5.18': {} '@next/eslint-plugin-next@15.5.18': @@ -5875,12 +6374,24 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/core@3.24.3': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.1': dependencies: '@smithy/core': 3.24.1 '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5902,6 +6413,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/hash-blob-browser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5972,6 +6489,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/property-provider@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5998,6 +6521,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/signature-v4@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/smithy-client@4.13.1': dependencies: '@smithy/core': 3.24.1 @@ -6008,6 +6537,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -6093,10 +6626,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.22.3))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0) + tailwindcss: 3.4.19(tsx@4.22.3) '@tanstack/query-core@5.100.10': {} @@ -6652,17 +7185,6 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bullmq@5.76.10: - dependencies: - cron-parser: 4.9.0 - ioredis: 5.10.1 - msgpackr: 2.0.1 - node-abort-controller: 3.1.1 - semver: 7.8.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6727,8 +7249,6 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.2: {} - cmpstr@3.3.0: {} color-convert@2.0.1: @@ -6779,10 +7299,6 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - cron-parser@4.9.0: - dependencies: - luxon: 3.7.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6854,8 +7370,6 @@ snapshots: defu@6.1.7: {} - denque@2.1.0: {} - dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -7138,6 +7652,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -7429,6 +7972,13 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.3.0 + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fast-xml-parser@5.8.0: dependencies: '@nodable/entities': 2.1.0 @@ -7701,20 +8251,6 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 - ioredis@5.10.1: - dependencies: - '@ioredis/commands': 1.5.1 - cluster-key-slot: 1.1.2 - debug: 4.4.3 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ipaddr.js@2.4.0: {} is-alphabetical@2.0.1: {} @@ -7960,10 +8496,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.defaults@4.2.0: {} - - lodash.isarguments@3.1.0: {} - lodash.merge@4.6.2: {} lodash@4.18.1: {} @@ -7978,8 +8510,6 @@ snapshots: lru-cache@11.3.6: {} - luxon@3.7.2: {} - make-cancellable-promise@1.3.2: {} make-event-props@1.6.2: {} @@ -8375,22 +8905,6 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.3: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 - optional: true - - msgpackr@2.0.1: - optionalDependencies: - msgpackr-extract: 3.0.3 - mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -8437,8 +8951,6 @@ snapshots: dependencies: semver: 7.8.0 - node-abort-controller@3.1.1: {} - node-addon-api@7.1.1: optional: true @@ -8449,11 +8961,6 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - normalize-path@3.0.0: {} object-assign@4.1.1: {} @@ -8653,6 +9160,20 @@ snapshots: pino-std-serializers@7.1.0: {} + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pino@9.14.0: dependencies: '@pinojs/redact': 0.4.0 @@ -8691,13 +9212,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.14 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.14 - tsx: 4.21.0 + tsx: 4.22.3 postcss-nested@6.2.0(postcss@8.5.14): dependencies: @@ -8936,11 +9457,7 @@ snapshots: real-require@0.2.0: {} - redis-errors@1.2.0: {} - - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 + real-require@1.0.0: {} redux@4.2.1: dependencies: @@ -9202,8 +9719,6 @@ snapshots: stable-hash@0.0.5: {} - standard-as-callback@2.1.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -9344,7 +9859,7 @@ snapshots: tabbable@6.4.0: {} - tailwindcss@3.4.19(tsx@4.21.0): + tailwindcss@3.4.19(tsx@4.22.3): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -9363,7 +9878,7 @@ snapshots: postcss: 8.5.14 postcss-import: 15.1.0(postcss@8.5.14) postcss-js: 4.1.0(postcss@8.5.14) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.3) postcss-nested: 6.2.0(postcss@8.5.14) postcss-selector-parser: 6.1.2 resolve: 1.22.12 @@ -9423,6 +9938,10 @@ snapshots: dependencies: real-require: 0.2.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-invariant@1.3.3: {} tinyglobby@0.2.16: @@ -9462,10 +9981,18 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From b30304a119af6163e4b898f7b1e80e6f6024a42f Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 12:25:13 -0600 Subject: [PATCH 023/137] docs(worker): document NATS credentials options and update authentication support Expand compute worker documentation to explain NATS authentication using either a credentials file or raw credentials string, including Synadia Cloud integration steps. Update environment variable examples and server connection logic to support both `NATS_CREDS_FILE` and `NATS_CREDS`. Add `.creds` to .gitignore to avoid accidental credential leaks. This improves deployment flexibility for cloud and on-prem environments by supporting multiple authentication methods for NATS JetStream. --- .gitignore | 1 + compute/worker/.env.example | 5 +++++ compute/worker/src/server.ts | 17 ++++++++++++++++- docs-site/docs/deploy/compute-worker.md | 25 +++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d5cc68..c006683 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env .dockerignore +*.creds # vercel .vercel diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 4a36fdc..901417a 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -9,6 +9,11 @@ COMPUTE_WORKER_TOKEN=local-compute-token # NATS/JetStream NATS_URL=nats://nats:4222 +# Optional: NATS authentication credentials (e.g. for Synadia Cloud / NGS) +# You can specify the file path: +# NATS_CREDS_FILE=/path/to/NGS-Default-compute-worker.creds +# Or specify the raw credentials string content (excellent for container/cloud platforms): +# NATS_CREDS="-----BEGIN NATS USER JWT-----\n...\n-----BEGIN USER NKEY SEED-----\n..." # Shared object storage (must be reachable from worker) S3_BUCKET=openreader-documents diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 8b4263b..66551aa 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { connect, nanos, + credsAuthenticator, type NatsConnection, } from '@nats-io/transport-node'; import { @@ -412,7 +413,21 @@ async function main(): Promise { const attempts = readIntEnv('COMPUTE_JOB_ATTEMPTS', 2); const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); - const nc: NatsConnection = await connect({ servers: natsUrl }); + const connectOpts: any = { servers: natsUrl }; + const natsCreds = process.env.NATS_CREDS?.trim(); + const natsCredsFile = process.env.NATS_CREDS_FILE?.trim(); + + if (natsCreds) { + console.log('[compute-worker] Connecting to NATS using credentials string from NATS_CREDS'); + connectOpts.authenticator = credsAuthenticator(new TextEncoder().encode(natsCreds)); + } else if (natsCredsFile) { + console.log(`[compute-worker] Connecting to NATS using credentials file: ${natsCredsFile}`); + const { readFileSync } = require('fs'); + const credsData = readFileSync(natsCredsFile); + connectOpts.authenticator = credsAuthenticator(credsData); + } + + const nc: NatsConnection = await connect(connectOpts); const js: JetStreamClient = jetstream(nc); const jsm: JetStreamManager = await jetstreamManager(nc); diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index f57ce9a..16c3d8d 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -28,8 +28,15 @@ Required: - `S3_ACCESS_KEY_ID` - `S3_SECRET_ACCESS_KEY` +> [!IMPORTANT] +> **S3 credentials cannot be left blank/empty** when running in worker mode. +> While the main Next.js server can generate random, dynamic S3 keys on-the-fly when `USE_EMBEDDED_WEED_MINI=true` and `S3_*` vars are blank, the compute worker runs in a separate process and cannot connect to SeaweedFS using those dynamically generated keys. +> To use the compute worker with the embedded SeaweedFS, you **must configure identical, stable S3 credentials** (e.g. `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`) in both the root `.env` and the compute worker `.env` files. + Common optional: +- `NATS_CREDS`: raw user credentials file content (JWT + private key), ideal for cloud container environments where mounting files is difficult. +- `NATS_CREDS_FILE`: path to a `.creds` file on the server. - `S3_ENDPOINT` (for non-AWS S3-compatible storage) - `S3_FORCE_PATH_STYLE=true` (for many S3-compatible providers) - `S3_PREFIX=openreader` @@ -60,3 +67,21 @@ COMPUTE_WORKER_TOKEN= - `GET /health/live` - `GET /health/ready` + +## Authenticating with Synadia Cloud (NGS) + +If you are using a free Synadia Cloud account to back your compute queue in production: + +1. **Obtain your credentials file**: When creating a user or a service account on Synadia Cloud, download your credentials file (usually named `.creds`). +2. **Configure NATS URL**: Synadia Cloud's server address is `tls://connect.ngs.global:4222`. Set this as your `NATS_URL`. +3. **Configure Authentication**: + - **Using a local file path**: Set `NATS_CREDS_FILE` to the path of your `.creds` file: + ```env + NATS_URL=tls://connect.ngs.global:4222 + NATS_CREDS_FILE=/app/secrets/NGS-Default-compute-worker.creds + ``` + - **Using raw content (Recommended for Railway, Fly.io, etc.)**: Set `NATS_CREDS` to the exact content of your `.creds` file (including the begin/end banners for JWT and NKEY seed). Since `.creds` contains newlines, wrap the entire value in quotes or paste it directly into your cloud provider's Secrets/Environment settings: + ```env + NATS_URL=tls://connect.ngs.global:4222 + NATS_CREDS="-----BEGIN NATS USER JWT-----\neyJ0...------END USER NKEY SEED------" + ``` From 3a1dd41d916ed4c955eb0006ccf3a778461d9860 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 13:21:12 -0600 Subject: [PATCH 024/137] refactor(build): update Dockerfiles for improved dependency isolation and workspace handling Remove legacy .npmrc config. Revise root and worker Dockerfiles to enhance layer caching, explicitly copy only required build artifacts, and streamline runtime dependencies. Switch to pnpm workspace-aware install for compute worker to ensure correct dependency resolution. Set Next.js output to standalone mode for optimized server builds. Update docker-compose sync targets for consistency. --- .npmrc | 1 - Dockerfile | 28 +++++++++++++++++++++------- compute/worker/Dockerfile | 17 +++++++++++++---- compute/worker/docker-compose.yml | 5 ++--- next.config.ts | 1 + 5 files changed, 37 insertions(+), 15 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d67f374..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -node-linker=hoisted diff --git a/Dockerfile b/Dockerfile index 0dcfe74..262bae0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,31 +45,45 @@ FROM node:lts-alpine AS runner # ffmpeg is provided by ffmpeg-static from node_modules. RUN apk add --no-cache ca-certificates libreoffice-writer -# Install pnpm globally for running the app -RUN npm install -g pnpm@11.1.2 +# drizzle-kit is used by scripts/openreader-entrypoint.mjs for startup migrations. +RUN npm install -g drizzle-kit@0.31.10 # App runtime directory WORKDIR /app -# Copy built app and dependencies from the builder stage -COPY --from=app-builder /app ./ +# Entry-point and migration scripts import dotenv directly. +RUN npm install --no-save dotenv@17.4.2 + +# Copy standalone Next.js server and required static assets. +COPY --from=app-builder /app/.next/standalone ./ +COPY --from=app-builder /app/.next/static ./.next/static +COPY --from=app-builder /app/public ./public +# Copy startup/migration scripts and migration files used by openreader-entrypoint. +COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs +COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs +COPY --from=app-builder /app/drizzle/scripts/migrate.mjs ./drizzle/scripts/migrate.mjs +COPY --from=app-builder /app/drizzle/sqlite ./drizzle/sqlite +COPY --from=app-builder /app/drizzle/postgres ./drizzle/postgres +COPY --from=app-builder /app/drizzle.config.sqlite.ts ./drizzle.config.sqlite.ts +COPY --from=app-builder /app/drizzle.config.pg.ts ./drizzle.config.pg.ts +COPY --from=app-builder /app/src/db ./src/db # Include third-party license report and copied license texts at a stable path in the image. COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses # Include SeaweedFS license text for the copied weed binary. COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt # Include static model notices for runtime-downloaded assets. -COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt +COPY --from=app-builder /app/src/lib/server/pdf-layout/model/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed # Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts. -COPY src/lib/server/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt +COPY --from=app-builder /app/src/lib/server/whisper/model/LICENSE.txt /licenses/openai-whisper-LICENSE.txt # Expose the port the app runs on EXPOSE 3003 # Start the application ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"] -CMD ["pnpm", "start:raw"] +CMD ["node", "server.js"] diff --git a/compute/worker/Dockerfile b/compute/worker/Dockerfile index cb0c4a5..2e6d613 100644 --- a/compute/worker/Dockerfile +++ b/compute/worker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts +FROM node:lts AS deploy-stage RUN npm install -g pnpm@11.1.2 @@ -8,13 +8,22 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY compute/worker/package.json compute/worker/package.json COPY compute/core/package.json compute/core/package.json -RUN pnpm install --frozen-lockfile +COPY compute/worker compute/worker +COPY compute/core compute/core -COPY . . +RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker + +FROM node:lts + +RUN npm install -g pnpm@11.1.2 + +WORKDIR /workspace + +COPY --from=deploy-stage /opt/compute-worker ./ ENV COMPUTE_WORKER_HOST=0.0.0.0 ENV COMPUTE_WORKER_PORT=8081 EXPOSE 8081 -CMD ["pnpm", "--filter", "@openreader/compute-worker", "dev"] +CMD ["pnpm", "dev"] diff --git a/compute/worker/docker-compose.yml b/compute/worker/docker-compose.yml index 269c2d7..be053e7 100644 --- a/compute/worker/docker-compose.yml +++ b/compute/worker/docker-compose.yml @@ -31,10 +31,9 @@ services: watch: - action: sync+restart path: . - target: /workspace/compute/worker - - action: sync+restart + target: /workspace + - action: rebuild path: ../../compute/core - target: /workspace/compute/core - action: rebuild path: ./package.json - action: rebuild diff --git a/next.config.ts b/next.config.ts index 7e54e19..3340a9e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -28,6 +28,7 @@ const serverExternalPackages = [ ]; const nextConfig: NextConfig = { + output: 'standalone', async headers() { return [ { From 8310091816e3c597ea1c4bd50a1eaa303c93c9d6 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 14:17:07 -0600 Subject: [PATCH 025/137] docs(deploy): update compute worker and Vercel deployment guides for Synadia + Railway Add a comprehensive Synadia Cloud and Railway setup guide to the compute worker deployment docs, including step-by-step environment variable configuration and example image tags. Update Vercel deployment instructions with a quick start for external compute worker integration via Railway and Synadia NGS. Document new `.env.prod` pattern in .gitignore for production environment management. --- .gitignore | 1 + docs-site/docs/deploy/compute-worker.md | 84 ++++++++++++++++++---- docs-site/docs/deploy/vercel-deployment.md | 14 +++- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index c006683..4014f63 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env +.env.prod .dockerignore *.creds diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 16c3d8d..8e2da44 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -16,6 +16,7 @@ The app server enqueues jobs and polls status. Queue durability and retries are - App server image: `ghcr.io/richardr1126/openreader` - Compute worker image: `ghcr.io/richardr1126/openreader-compute-worker` +- Compute worker image (example pinned tag): `ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing` ## Worker environment variables @@ -68,20 +69,73 @@ COMPUTE_WORKER_TOKEN= - `GET /health/live` - `GET /health/ready` -## Authenticating with Synadia Cloud (NGS) +## Synadia Cloud + Railway Setup (Complete Guide) -If you are using a free Synadia Cloud account to back your compute queue in production: +Use this end-to-end guide when your queue backend is Synadia Cloud (NGS) and your worker runs on Railway. -1. **Obtain your credentials file**: When creating a user or a service account on Synadia Cloud, download your credentials file (usually named `.creds`). -2. **Configure NATS URL**: Synadia Cloud's server address is `tls://connect.ngs.global:4222`. Set this as your `NATS_URL`. -3. **Configure Authentication**: - - **Using a local file path**: Set `NATS_CREDS_FILE` to the path of your `.creds` file: - ```env - NATS_URL=tls://connect.ngs.global:4222 - NATS_CREDS_FILE=/app/secrets/NGS-Default-compute-worker.creds - ``` - - **Using raw content (Recommended for Railway, Fly.io, etc.)**: Set `NATS_CREDS` to the exact content of your `.creds` file (including the begin/end banners for JWT and NKEY seed). Since `.creds` contains newlines, wrap the entire value in quotes or paste it directly into your cloud provider's Secrets/Environment settings: - ```env - NATS_URL=tls://connect.ngs.global:4222 - NATS_CREDS="-----BEGIN NATS USER JWT-----\neyJ0...------END USER NKEY SEED------" - ``` +### 1. Create Synadia account and credentials + +1. Create a Synadia Cloud account and create/select your NGS environment. +2. Create a user or service account for OpenReader compute worker access. +3. Download the generated credentials file (usually `.creds`) and keep it secure. + +You will use: + +- `NATS_URL=tls://connect.ngs.global:4222` +- The full `.creds` file content + +### 2. Deploy compute worker on Railway + +Create a Railway service from: + +```text +ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing +``` + +Set the container port to `8081`, then enable public networking to get a worker URL. + +### 3. Configure Railway worker environment variables + +Set these in the Railway worker service: + +```env +COMPUTE_WORKER_HOST=0.0.0.0 +COMPUTE_WORKER_PORT=8081 +COMPUTE_WORKER_TOKEN= + +NATS_URL=tls://connect.ngs.global:4222 +NATS_CREDS="-----BEGIN NATS USER JWT----- +... +------END USER NKEY SEED------" + +S3_BUCKET= +S3_REGION= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_ENDPOINT= +S3_FORCE_PATH_STYLE=true +S3_PREFIX=openreader +``` + +Notes: + +- `NATS_CREDS` should be the full Synadia `.creds` file content, including begin/end markers. +- Keep `COMPUTE_WORKER_TOKEN` identical between app server and worker. +- If your platform supports mounted files, you can use `NATS_CREDS_FILE` instead of `NATS_CREDS`. + +### 4. Configure the OpenReader app server (worker mode) + +Set these env vars on the app server: + +```env +COMPUTE_MODE=worker +COMPUTE_WORKER_URL=https:// +COMPUTE_WORKER_TOKEN= +``` + +### 5. Verify health + +After deploy, check: + +- `GET https:///health/live` +- `GET https:///health/ready` diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 7fa3842..b587d1b 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -41,7 +41,7 @@ ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in # local = requires native binaries/models in-process (not recommended on Vercel) # worker = external durable compute worker (recommended) COMPUTE_MODE=worker -COMPUTE_WORKER_URL=https://your-compute-worker.example.com +COMPUTE_WORKER_URL=https:// COMPUTE_WORKER_TOKEN=... # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) @@ -53,6 +53,18 @@ COMPUTE_WORKER_TOKEN=... `API_KEY` / `API_BASE` are one-shot bootstrap seeds on first deploy. After boot, manage providers and site features in **Settings → Admin**. Changes there apply on refresh without a redeploy. See [Admin Panel](../configure/admin-panel). ::: +## 1a. Railway + Synadia quick start (worker mode) + +If your Vercel app uses an external compute worker on Railway with Synadia Cloud (NGS): + +1. Deploy a Railway service from: + - `ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing` +2. Enable public networking on that Railway service and set: + - `COMPUTE_WORKER_URL=https://` (in Vercel) +3. Use the same `COMPUTE_WORKER_TOKEN` value in both Vercel and Railway worker env vars. + +For complete Railway worker env vars (`NATS_*`, `S3_*`, health checks, and Synadia `.creds` guidance), see [Compute Worker (NATS JetStream)](./compute-worker). + ## 2. First-run admin configuration (recommended) After the first successful deploy and admin login, open **Settings → Admin** and configure: From 1bd13c02fe2a3e1cbf2fdb308af648b0738ab6b8 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 15:07:22 -0600 Subject: [PATCH 026/137] refactor(compute): modularize core contracts and runtime, enable flexible backend resolution Restructure compute core by extracting job contracts and runtime logic into separate modules (`contracts.ts`, `local-runtime.ts`) for improved modularity and clearer API boundaries. Update exports and imports across worker, server, and local compute backends to use the new modules. Enhance Next.js config to support dynamic backend selection via build-time constants and aliasing, enabling seamless switching between local and worker compute modes. Adjust TypeScript paths and Dockerfile entrypoint to align with the new structure. --- compute/core/package.json | 4 +- compute/core/src/contracts.ts | 62 +++++++++++++ compute/core/src/index.ts | 102 ++-------------------- compute/core/src/local-runtime.ts | 34 ++++++++ compute/worker/Dockerfile | 2 +- compute/worker/package.json | 4 +- compute/worker/src/server.ts | 4 +- next.config.ts | 33 ++++++- pnpm-lock.yaml | 7 +- src/lib/server/compute/index.ts | 7 ++ src/lib/server/compute/index.worker.ts | 20 +++++ src/lib/server/compute/local.ts | 2 +- src/lib/server/compute/types.ts | 2 +- src/lib/server/compute/worker-contract.ts | 2 +- tsconfig.json | 4 +- 15 files changed, 178 insertions(+), 111 deletions(-) create mode 100644 compute/core/src/contracts.ts create mode 100644 compute/core/src/local-runtime.ts create mode 100644 src/lib/server/compute/index.worker.ts diff --git a/compute/core/package.json b/compute/core/package.json index fe9bb45..9141f07 100644 --- a/compute/core/package.json +++ b/compute/core/package.json @@ -12,6 +12,8 @@ "pdfjs-dist": "4.8.69" }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./contracts": "./src/contracts.ts", + "./local-runtime": "./src/local-runtime.ts" } } diff --git a/compute/core/src/contracts.ts b/compute/core/src/contracts.ts new file mode 100644 index 0000000..04e1fb4 --- /dev/null +++ b/compute/core/src/contracts.ts @@ -0,0 +1,62 @@ +import type { TTSSentenceAlignment } from './types/tts'; +import type { ParsedPdfDocument } from './types/parsed-pdf'; + +export type { + TTSAudioBuffer, + TTSAudioBytes, + TTSSentenceAlignment, + TTSSentenceWord, +} from './types/tts'; +export type { + ParsedPdfBlockKind, + ParsedPdfBlockFragment, + ParsedPdfBlock, + ParsedPdfPage, + ParsedPdfDocument, +} from './types/parsed-pdf'; + +export const ALIGN_QUEUE_NAME = 'whisper-align'; +export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout'; + +export interface WhisperAlignJobRequest { + text: string; + lang?: string; + cacheKey?: string; + audioObjectKey: string; +} + +export interface WhisperAlignJobResult { + alignments: TTSSentenceAlignment[]; + timing?: WorkerJobTiming; +} + +export interface PdfLayoutJobRequest { + documentId: string; + namespace: string | null; + documentObjectKey: string; +} + +export interface PdfLayoutJobResult { + parsed: ParsedPdfDocument; + timing?: WorkerJobTiming; +} + +export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; + +export interface WorkerJobErrorShape { + message: string; + code?: string; +} + +export interface WorkerJobTiming { + queueWaitMs?: number; + s3FetchMs?: number; + computeMs?: number; +} + +export interface WorkerJobStatusResponse { + status: WorkerJobState; + result?: Result; + error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; +} diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index 24afe87..54397e2 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -1,96 +1,6 @@ -import type { TTSSentenceAlignment } from './types/tts'; -import type { ParsedPdfDocument } from './types/parsed-pdf'; -import { ensureWhisperModel } from './whisper/ensureModel'; -import { alignAudioWithText } from './whisper/alignment'; -import { ensureModel as ensurePdfLayoutModel } from './pdf-layout/ensureModel'; -import { parsePdf } from './pdf-layout/parsePdf'; - -export type { - TTSAudioBuffer, - TTSAudioBytes, - TTSSentenceAlignment, - TTSSentenceWord, -} from './types/tts'; -export type { - ParsedPdfBlockKind, - ParsedPdfBlockFragment, - ParsedPdfBlock, - ParsedPdfPage, - ParsedPdfDocument, -} from './types/parsed-pdf'; - -export const ALIGN_QUEUE_NAME = 'whisper-align'; -export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout'; - -export interface WhisperAlignJobRequest { - text: string; - lang?: string; - cacheKey?: string; - audioObjectKey: string; -} - -export interface WhisperAlignJobResult { - alignments: TTSSentenceAlignment[]; - timing?: WorkerJobTiming; -} - -export interface PdfLayoutJobRequest { - documentId: string; - namespace: string | null; - documentObjectKey: string; -} - -export interface PdfLayoutJobResult { - parsed: ParsedPdfDocument; - timing?: WorkerJobTiming; -} - -export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; - -export interface WorkerJobErrorShape { - message: string; - code?: string; -} - -export interface WorkerJobTiming { - queueWaitMs?: number; - s3FetchMs?: number; - computeMs?: number; -} - -export interface WorkerJobStatusResponse { - status: WorkerJobState; - result?: Result; - error?: WorkerJobErrorShape; - timing?: WorkerJobTiming; -} - -export async function ensureComputeModels(): Promise { - await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]); -} - -export async function runWhisperAlignmentFromAudioBuffer(input: { - audioBuffer: ArrayBuffer; - text: string; - cacheKey?: string; - lang?: string; -}): Promise { - const alignments = await alignAudioWithText( - input.audioBuffer, - input.text, - input.cacheKey, - { lang: input.lang }, - ); - return { alignments }; -} - -export async function runPdfLayoutFromPdfBuffer(input: { - documentId: string; - pdfBytes: ArrayBuffer; -}): Promise { - const parsed = await parsePdf({ - documentId: input.documentId, - pdfBytes: input.pdfBytes, - }); - return { parsed }; -} +export * from './contracts'; +export { + ensureComputeModels, + runPdfLayoutFromPdfBuffer, + runWhisperAlignmentFromAudioBuffer, +} from './local-runtime'; diff --git a/compute/core/src/local-runtime.ts b/compute/core/src/local-runtime.ts new file mode 100644 index 0000000..5a794b8 --- /dev/null +++ b/compute/core/src/local-runtime.ts @@ -0,0 +1,34 @@ +import { ensureWhisperModel } from './whisper/ensureModel'; +import { alignAudioWithText } from './whisper/alignment'; +import { ensureModel as ensurePdfLayoutModel } from './pdf-layout/ensureModel'; +import { parsePdf } from './pdf-layout/parsePdf'; + +export async function ensureComputeModels(): Promise { + await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]); +} + +export async function runWhisperAlignmentFromAudioBuffer(input: { + audioBuffer: ArrayBuffer; + text: string; + cacheKey?: string; + lang?: string; +}) { + const alignments = await alignAudioWithText( + input.audioBuffer, + input.text, + input.cacheKey, + { lang: input.lang }, + ); + return { alignments }; +} + +export async function runPdfLayoutFromPdfBuffer(input: { + documentId: string; + pdfBytes: ArrayBuffer; +}) { + const parsed = await parsePdf({ + documentId: input.documentId, + pdfBytes: input.pdfBytes, + }); + return { parsed }; +} diff --git a/compute/worker/Dockerfile b/compute/worker/Dockerfile index 2e6d613..9872e70 100644 --- a/compute/worker/Dockerfile +++ b/compute/worker/Dockerfile @@ -26,4 +26,4 @@ ENV COMPUTE_WORKER_PORT=8081 EXPOSE 8081 -CMD ["pnpm", "dev"] +CMD ["node", "--import", "tsx", "src/server.ts"] diff --git a/compute/worker/package.json b/compute/worker/package.json index bd85999..4b3b1c7 100644 --- a/compute/worker/package.json +++ b/compute/worker/package.json @@ -16,9 +16,7 @@ "fastify": "^5.6.2", "pino": "^10.3.1", "pino-pretty": "^13.1.2", + "tsx": "^4.22.3", "zod": "^4.1.12" - }, - "devDependencies": { - "tsx": "^4.22.3" } } diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 66551aa..4e62162 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -24,13 +24,15 @@ import { ensureComputeModels, runPdfLayoutFromPdfBuffer, runWhisperAlignmentFromAudioBuffer, +} from '@openreader/compute-core/local-runtime'; +import { type PdfLayoutJobRequest, type PdfLayoutJobResult, type WhisperAlignJobRequest, type WhisperAlignJobResult, type WorkerJobStatusResponse, type WorkerJobTiming, -} from '@openreader/compute-core'; +} from '@openreader/compute-core/contracts'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; const JOBS_STREAM_NAME = 'compute_jobs'; diff --git a/next.config.ts b/next.config.ts index 3340a9e..73e514e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import path from "node:path"; const securityHeaders = [ { key: 'X-Content-Type-Options', value: 'nosniff' }, @@ -43,7 +44,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - transpilePackages: ['@openreader/compute-core'], + transpilePackages: computeLocal ? ['@openreader/compute-core'] : [], serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ @@ -75,6 +76,36 @@ const nextConfig: NextConfig = { }, } : {}), + webpack: (config, { isServer }) => { + if (isServer) { + // Use runtime require to avoid adding an explicit webpack TS dependency. + const { DefinePlugin } = require('webpack') as { DefinePlugin: new (defs: Record) => unknown }; + config.plugins = config.plugins || []; + config.plugins.push( + new DefinePlugin({ + __OPENREADER_COMPUTE_MODE__: JSON.stringify(computeMode), + }), + ); + } + if (isServer && !computeLocal) { + const workerComputeEntry = path.resolve(__dirname, 'src/lib/server/compute/index.worker.ts'); + const computeIndexTs = path.resolve(__dirname, 'src/lib/server/compute/index.ts'); + const computeIndexNoExt = path.resolve(__dirname, 'src/lib/server/compute/index'); + const computeDir = path.resolve(__dirname, 'src/lib/server/compute'); + config.resolve.alias = { + ...(config.resolve.alias || {}), + '@/lib/server/compute$': workerComputeEntry, + '@/lib/server/compute/index$': workerComputeEntry, + [`${computeIndexTs}$`]: workerComputeEntry, + [`${computeIndexNoExt}$`]: workerComputeEntry, + [`${computeDir}$`]: workerComputeEntry, + '@openreader/compute-core/local-runtime$': false, + 'onnxruntime-node$': false, + '@huggingface/tokenizers$': false, + }; + } + return config; + }, }; export default nextConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7303f48..fdbeb0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,13 +233,12 @@ importers: pino-pretty: specifier: ^13.1.2 version: 13.1.3 - zod: - specifier: ^4.1.12 - version: 4.4.3 - devDependencies: tsx: specifier: ^4.22.3 version: 4.22.3 + zod: + specifier: ^4.1.12 + version: 4.4.3 packages: diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index 5442429..c83d790 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -2,9 +2,16 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; import { WorkerComputeBackend } from '@/lib/server/compute/worker'; +declare const __OPENREADER_COMPUTE_MODE__: 'local' | 'worker' | 'none'; + let backendPromise: Promise | null = null; async function createBackend(): Promise { + if (__OPENREADER_COMPUTE_MODE__ === 'worker') return new WorkerComputeBackend(); + if (__OPENREADER_COMPUTE_MODE__ === 'local') { + const { LocalComputeBackend } = await import('@/lib/server/compute/local'); + return new LocalComputeBackend(); + } const mode: ComputeMode = readComputeMode(); if (mode === 'worker') return new WorkerComputeBackend(); const { LocalComputeBackend } = await import('@/lib/server/compute/local'); diff --git a/src/lib/server/compute/index.worker.ts b/src/lib/server/compute/index.worker.ts new file mode 100644 index 0000000..fbbaf7b --- /dev/null +++ b/src/lib/server/compute/index.worker.ts @@ -0,0 +1,20 @@ +import type { ComputeBackend } from '@/lib/server/compute/types'; +import { WorkerComputeBackend } from '@/lib/server/compute/worker'; + +let backendPromise: Promise | null = null; + +export async function getCompute(): Promise { + if (!backendPromise) { + backendPromise = Promise.resolve() + .then(() => new WorkerComputeBackend()) + .catch((error) => { + backendPromise = null; + throw error; + }); + } + return backendPromise; +} + +export function isComputeAvailable(): boolean { + return true; +} diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index c1f1475..41cea36 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -4,7 +4,7 @@ import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; import { runPdfLayoutFromPdfBuffer, runWhisperAlignmentFromAudioBuffer, -} from '@openreader/compute-core'; +} from '@openreader/compute-core/local-runtime'; export class LocalComputeBackend implements ComputeBackend { readonly mode = 'local' as const; diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index f316756..4a5ce41 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -1,4 +1,4 @@ -import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core'; +import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core/contracts'; export type ComputeMode = 'local' | 'worker'; diff --git a/src/lib/server/compute/worker-contract.ts b/src/lib/server/compute/worker-contract.ts index b43cae9..a93c984 100644 --- a/src/lib/server/compute/worker-contract.ts +++ b/src/lib/server/compute/worker-contract.ts @@ -9,4 +9,4 @@ export { type WorkerJobState, type WorkerJobTiming, type WorkerJobStatusResponse, -} from '@openreader/compute-core'; +} from '@openreader/compute-core/contracts'; diff --git a/tsconfig.json b/tsconfig.json index 19d4e79..a06fb0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,9 @@ ], "paths": { "@/*": ["./src/*"], - "@openreader/compute-core": ["./compute/core/src/index.ts"] + "@openreader/compute-core": ["./compute/core/src/index.ts"], + "@openreader/compute-core/contracts": ["./compute/core/src/contracts.ts"], + "@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From 4ddedee8ec404ceeb07ec91a4da070f3d9a81c4b Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 15:34:17 -0600 Subject: [PATCH 027/137] docs(worker): document and expose JetStream storage limits for jobs and KV state Add `COMPUTE_JOBS_STREAM_MAX_BYTES` and `COMPUTE_JOB_STATES_MAX_BYTES` to environment example, worker server configuration, and deployment docs. These variables allow tuning JetStream resource caps for job queue and state storage to better fit deployment requirements. --- compute/worker/.env.example | 2 ++ compute/worker/src/server.ts | 8 +++++++- docs-site/docs/deploy/compute-worker.md | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 901417a..36a9939 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -27,3 +27,5 @@ S3_FORCE_PATH_STYLE=true # Queue + execution tuning COMPUTE_PREWARM_MODELS=true +COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 +COMPUTE_JOB_STATES_MAX_BYTES=67108864 diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 4e62162..c59150e 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -243,12 +243,14 @@ async function ensureJetStreamResources( whisperTimeoutMs: number, pdfTimeoutMs: number, attempts: number, + maxBytes: number, ): Promise { const streamConfig = { name: JOBS_STREAM_NAME, subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], retention: RetentionPolicy.Workqueue, storage: StorageType.File, + max_bytes: maxBytes, }; try { @@ -257,6 +259,7 @@ async function ensureJetStreamResources( if (!isAlreadyExistsError(error)) throw error; await jsm.streams.update(JOBS_STREAM_NAME, { subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], + max_bytes: maxBytes, }); } @@ -414,6 +417,8 @@ async function main(): Promise { const pdfTimeoutMs = readIntEnv('COMPUTE_PDF_TIMEOUT_MS', 90_000); const attempts = readIntEnv('COMPUTE_JOB_ATTEMPTS', 2); const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); + const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024); + const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024); const connectOpts: any = { servers: natsUrl }; const natsCreds = process.env.NATS_CREDS?.trim(); @@ -433,11 +438,12 @@ async function main(): Promise { const js: JetStreamClient = jetstream(nc); const jsm: JetStreamManager = await jetstreamManager(nc); - await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, attempts); + await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, attempts, jobsStreamMaxBytes); const kv = await new Kvm(js).create(JOB_STATES_BUCKET, { history: 1, ttl: JOB_STATES_TTL_MS, + max_bytes: jobStatesMaxBytes, }); const s3 = buildS3Client(); diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 8e2da44..f5b0885 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -45,6 +45,8 @@ Common optional: - `COMPUTE_WORKER_PORT=8081` - `COMPUTE_LOG_FORMAT=pretty` (default) or `json` - `COMPUTE_PREWARM_MODELS=true` +- `COMPUTE_JOBS_STREAM_MAX_BYTES=268435456` (256MB JetStream jobs stream cap) +- `COMPUTE_JOB_STATES_MAX_BYTES=67108864` (64MB JetStream KV bucket cap) ## App server environment variables (worker mode) @@ -102,6 +104,8 @@ Set these in the Railway worker service: COMPUTE_WORKER_HOST=0.0.0.0 COMPUTE_WORKER_PORT=8081 COMPUTE_WORKER_TOKEN= +COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 +COMPUTE_JOB_STATES_MAX_BYTES=67108864 NATS_URL=tls://connect.ngs.global:4222 NATS_CREDS="-----BEGIN NATS USER JWT----- @@ -122,6 +126,7 @@ Notes: - `NATS_CREDS` should be the full Synadia `.creds` file content, including begin/end markers. - Keep `COMPUTE_WORKER_TOKEN` identical between app server and worker. - If your platform supports mounted files, you can use `NATS_CREDS_FILE` instead of `NATS_CREDS`. +- `COMPUTE_JOBS_STREAM_MAX_BYTES` and `COMPUTE_JOB_STATES_MAX_BYTES` are optional; defaults are `268435456` (256MiB) and `67108864` (64MiB). ### 4. Configure the OpenReader app server (worker mode) From 88809b826b05bb098433388148f9f8490899d023 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 15:38:24 -0600 Subject: [PATCH 028/137] fix(compute): unify PDF layout result handling with object key support Update compute backend interfaces and job logic to consistently handle parsed PDF layout results as either direct data or S3 object key references. Refactor result types, local and worker backends, and job processing to support this pattern. Improves flexibility for large document parsing and object storage integration. --- compute/core/src/contracts.ts | 15 +++++++++---- compute/worker/src/server.ts | 36 ++++++++++++++++++++++++++---- src/lib/server/compute/local.ts | 2 +- src/lib/server/compute/types.ts | 6 ++++- src/lib/server/compute/worker.ts | 8 ++++++- src/lib/server/jobs/parsePdfJob.ts | 10 ++++++--- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/compute/core/src/contracts.ts b/compute/core/src/contracts.ts index 04e1fb4..e97e672 100644 --- a/compute/core/src/contracts.ts +++ b/compute/core/src/contracts.ts @@ -36,10 +36,17 @@ export interface PdfLayoutJobRequest { documentObjectKey: string; } -export interface PdfLayoutJobResult { - parsed: ParsedPdfDocument; - timing?: WorkerJobTiming; -} +export type PdfLayoutJobResult = + | { + parsed: ParsedPdfDocument; + parsedObjectKey?: never; + timing?: WorkerJobTiming; + } + | { + parsed?: never; + parsedObjectKey: string; + timing?: WorkerJobTiming; + }; export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index c59150e..1b44af5 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -33,7 +33,7 @@ import { type WorkerJobStatusResponse, type WorkerJobTiming, } from '@openreader/compute-core/contracts'; -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; const JOBS_STREAM_NAME = 'compute_jobs'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; @@ -44,6 +44,8 @@ const JOB_STATES_BUCKET = 'job_states'; const JOB_STATES_TTL_MS = 24 * 60 * 60 * 1000; const PULL_EXPIRES_MS = 1000; const LOOP_ERROR_BACKOFF_MS = 500; +const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; interface QueuedJob { jobId: string; @@ -104,6 +106,20 @@ function normalizeS3Prefix(prefix: string | undefined): string { return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader'; } +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + return SAFE_NAMESPACE_REGEX.test(namespace) ? namespace : null; +} + +function documentParsedKey(id: string, namespace: string | null, prefix: string): string { + if (!DOCUMENT_ID_REGEX.test(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${prefix}/documents_v1/parsed_v1/${nsSegment}${id}.json`; +} + function buildS3Client(): S3Client { const bucket = requireEnv('S3_BUCKET'); const region = requireEnv('S3_REGION'); @@ -468,6 +484,19 @@ async function main(): Promise { return toArrayBuffer(new Uint8Array(bytes)); }; + const putParsedObject = async (documentId: string, namespace: string | null, parsed: unknown): Promise => { + const key = documentParsedKey(documentId, namespace, s3Prefix); + const body = Buffer.from(JSON.stringify(parsed)); + await s3.send(new PutObjectCommand({ + Bucket: s3Bucket, + Key: key, + Body: body, + ContentType: 'application/json', + ServerSideEncryption: 'AES256', + })); + return key; + }; + if (prewarmModels) { await ensureComputeModels(); } @@ -638,7 +667,6 @@ async function main(): Promise { return { ...result, timing: { - ...(result.timing ?? {}), queueWaitMs, s3FetchMs, computeMs, @@ -667,10 +695,10 @@ async function main(): Promise { ); const computeMs = Date.now() - computeStartedAt; + const parsedObjectKey = await putParsedObject(parsed.documentId, parsed.namespace, result.parsed); return { - ...result, + parsedObjectKey, timing: { - ...(result.timing ?? {}), queueWaitMs, s3FetchMs, computeMs, diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index 41cea36..0315682 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -35,6 +35,6 @@ export class LocalComputeBackend implements ComputeBackend { if (!pdfBytes) { throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); } - return (await runPdfLayoutFromPdfBuffer({ documentId: input.documentId, pdfBytes })).parsed; + return { parsed: (await runPdfLayoutFromPdfBuffer({ documentId: input.documentId, pdfBytes })).parsed }; } } diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index 4a5ce41..aacbb10 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -21,8 +21,12 @@ export interface PdfLayoutInput { pdfBytes?: ArrayBuffer; } +export type PdfLayoutResult = + | { parsed: ParsedPdfDocument; parsedObjectKey?: never } + | { parsed?: never; parsedObjectKey: string }; + export interface ComputeBackend { mode: ComputeMode; alignWords(input: WhisperAlignInput): Promise; - parsePdfLayout(input: PdfLayoutInput): Promise; + parsePdfLayout(input: PdfLayoutInput): Promise; } diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 79af3ba..5205e28 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -124,7 +124,13 @@ export class WorkerComputeBackend implements ComputeBackend { if (status.status !== 'succeeded' || !status.result) { throw new Error(status.error?.message || 'PDF layout worker job did not complete'); } - return status.result.parsed; + if (status.result.parsedObjectKey) { + return { parsedObjectKey: status.result.parsedObjectKey }; + } + if (status.result.parsed) { + return { parsed: status.result.parsed }; + } + throw new Error('PDF layout worker job completed without parsed output'); }); } diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index c64cea9..593d06d 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -29,14 +29,18 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); const compute = await getCompute(); - const parsed = await compute.parsePdfLayout({ + const layout = await compute.parsePdfLayout({ documentId: input.documentId, namespace: input.namespace, documentObjectKey: documentKey(input.documentId, input.namespace), }); - const parsedJson = Buffer.from(JSON.stringify(parsed)); - const parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); + let parsedJsonKey = layout.parsedObjectKey ?? null; + if (!parsedJsonKey) { + if (!layout.parsed) throw new Error('Compute backend did not return parsed result'); + const parsedJson = Buffer.from(JSON.stringify(layout.parsed)); + parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); + } const cleared = await clearTtsSegmentCache({ userId: input.userId, From 944b8246e5c6becb816c9a5e859c3374201ed44e Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 15:42:45 -0600 Subject: [PATCH 029/137] refactor(context): expose provider config type and instruction flags in TTS value Update TTS context to include configProviderType, supportsInstructions, and ttsInstructions in the provided value, improving downstream access to provider capabilities and instruction handling. --- src/contexts/TTSContext.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1972448..404a014 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -2264,6 +2264,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions, + ttsInstructions, isEPUB, currDocPage, currDocPageNumber, From fb32dea73cb7ff35ae3cdfe9bad99bc53de5e5b0 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 15:53:26 -0600 Subject: [PATCH 030/137] build(config): adjust output and file tracing for Vercel and local compute modes Update Next.js configuration to conditionally set 'output' only when not on Vercel, and always define 'outputFileTracingExcludes' with dynamic includes based on compute mode. This ensures compatibility with Vercel deployments and improves local/server build behavior. --- next.config.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/next.config.ts b/next.config.ts index 73e514e..6df2903 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,7 @@ const computeMode = computeModeRaw === 'none' || computeModeRaw === 'worker' || ? computeModeRaw : 'local'; const computeLocal = computeMode === 'local'; +const isVercel = process.env.VERCEL === '1'; const serverExternalPackages = [ '@napi-rs/canvas', 'ffmpeg-static', @@ -29,7 +30,7 @@ const serverExternalPackages = [ ]; const nextConfig: NextConfig = { - output: 'standalone', + ...(isVercel ? {} : { output: 'standalone' }), async headers() { return [ { @@ -66,16 +67,17 @@ const nextConfig: NextConfig = { './node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs', ], }, - ...(!computeLocal - ? { - outputFileTracingExcludes: { - '/*': [ + outputFileTracingExcludes: { + '/*': [ + './docstore/**/*', + ...(!computeLocal + ? [ './node_modules/onnxruntime-node/**/*', './node_modules/@huggingface/tokenizers/**/*', - ], - }, - } - : {}), + ] + : []), + ], + }, webpack: (config, { isServer }) => { if (isServer) { // Use runtime require to avoid adding an explicit webpack TS dependency. From 29b7cfafb941cbf07b4b1366acfba8208e366192 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 16:26:07 -0600 Subject: [PATCH 031/137] chore(deps): update node engine requirement and clean server externals Remove 'ffmpeg-static' from server external packages in Next.js config and specify Node.js 22.x as required engine in package.json to align with deployment environment. --- next.config.ts | 1 - package.json | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index 6df2903..18fc239 100644 --- a/next.config.ts +++ b/next.config.ts @@ -24,7 +24,6 @@ const computeLocal = computeMode === 'local'; const isVercel = process.env.VERCEL === '1'; const serverExternalPackages = [ '@napi-rs/canvas', - 'ffmpeg-static', 'better-sqlite3', ...(computeLocal ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), ]; diff --git a/package.json b/package.json index 838d117..b3236fd 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "openreader", "version": "3.0.0", "private": true, + "engines": { + "node": "22.x" + }, "packageManager": "pnpm@10.33.4", "scripts": { "dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", From 91d9fe47a5e60f2afbf3d9a06dd36e176d367dd2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 16:39:25 -0600 Subject: [PATCH 032/137] refactor(compute): standardize compute mode detection for backend initialization Update backend creation logic to consistently determine compute mode using a local variable, ensuring correct handling when the mode is undefined. This improves clarity and robustness in backend selection. --- src/lib/server/compute/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index c83d790..25633e4 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -7,8 +7,11 @@ declare const __OPENREADER_COMPUTE_MODE__: 'local' | 'worker' | 'none'; let backendPromise: Promise | null = null; async function createBackend(): Promise { - if (__OPENREADER_COMPUTE_MODE__ === 'worker') return new WorkerComputeBackend(); - if (__OPENREADER_COMPUTE_MODE__ === 'local') { + const bundledMode = + typeof __OPENREADER_COMPUTE_MODE__ === 'undefined' ? 'none' : __OPENREADER_COMPUTE_MODE__; + + if (bundledMode === 'worker') return new WorkerComputeBackend(); + if (bundledMode === 'local') { const { LocalComputeBackend } = await import('@/lib/server/compute/local'); return new LocalComputeBackend(); } From 086329505fa4e06d4cc8ec8d300dd573d25f4576 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 17:01:14 -0600 Subject: [PATCH 033/137] fix(worker): improve environment variable handling and URL normalization Update worker server to prioritize platform-specific PORT environment variable when setting the compute worker port. Add robust normalization for COMPUTE_WORKER_URL, ensuring proper scheme and validation, and trim trailing slashes for consistency. This enhances reliability in diverse deployment environments. --- compute/worker/src/server.ts | 3 ++- src/lib/server/compute/worker.ts | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 1b44af5..cc19593 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -422,7 +422,8 @@ async function createWorkerLoop(input: { } async function main(): Promise { - const port = readIntEnv('COMPUTE_WORKER_PORT', 8081); + const platformPort = readIntEnv('PORT', 8081); + const port = readIntEnv('COMPUTE_WORKER_PORT', platformPort); const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; const workerToken = requireEnv('COMPUTE_WORKER_TOKEN'); const natsUrl = requireEnv('NATS_URL'); diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 5205e28..a2ae17f 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -30,6 +30,29 @@ function readRequiredEnv(name: string): string { return value; } +function normalizeWorkerBaseUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) throw new Error('COMPUTE_WORKER_URL is empty'); + + const withScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : (/^(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/|$)/.test(trimmed) + ? `http://${trimmed}` + : `https://${trimmed}`); + + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + throw new Error( + `Invalid COMPUTE_WORKER_URL="${raw}". Expected full URL like https://example.com (or http://localhost:4000).`, + ); + } + + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return parsed.toString().replace(/\/+$/, ''); +} + function parseRetryAfterMs(value: string | null): number | null { if (!value) return null; const asNum = Number(value); @@ -82,7 +105,7 @@ export class WorkerComputeBackend implements ComputeBackend { private readonly retries: number; constructor() { - this.baseUrl = readRequiredEnv('COMPUTE_WORKER_URL').replace(/\/+$/, ''); + this.baseUrl = normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')); this.token = readRequiredEnv('COMPUTE_WORKER_TOKEN'); this.waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS; this.retries = DEFAULT_RETRIES; From 49d973523784dcd942618ba086b69636392a80c2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 17:05:35 -0600 Subject: [PATCH 034/137] refactor(worker): unify port environment variable usage and update deployment docs Align compute worker to use PORT environment variable consistently across Dockerfile, docker-compose, and server code, replacing COMPUTE_WORKER_PORT. Update .env.example and deployment documentation to clarify platform-specific behavior, especially for Railway and similar environments. This simplifies configuration and reduces platform-specific issues. --- compute/worker/.env.example | 5 ++++- compute/worker/Dockerfile | 2 +- compute/worker/docker-compose.yml | 2 +- compute/worker/src/server.ts | 3 +-- docs-site/docs/deploy/compute-worker.md | 15 +++++++++++---- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 36a9939..766f0ce 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -1,6 +1,9 @@ # Compute worker bind +# Platform note: +# - Local/manual: keep PORT=8081 +# - Railway/Render/Fly/etc: platform injects PORT COMPUTE_WORKER_HOST=0.0.0.0 -COMPUTE_WORKER_PORT=8081 +PORT=8081 COMPUTE_LOG_FORMAT=pretty # COMPUTE_LOG_LEVEL=info diff --git a/compute/worker/Dockerfile b/compute/worker/Dockerfile index 9872e70..7ee779f 100644 --- a/compute/worker/Dockerfile +++ b/compute/worker/Dockerfile @@ -22,7 +22,7 @@ WORKDIR /workspace COPY --from=deploy-stage /opt/compute-worker ./ ENV COMPUTE_WORKER_HOST=0.0.0.0 -ENV COMPUTE_WORKER_PORT=8081 +ENV PORT=8081 EXPOSE 8081 diff --git a/compute/worker/docker-compose.yml b/compute/worker/docker-compose.yml index be053e7..dde5a73 100644 --- a/compute/worker/docker-compose.yml +++ b/compute/worker/docker-compose.yml @@ -21,7 +21,7 @@ services: environment: NATS_URL: ${NATS_URL:-nats://nats:4222} COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0} - COMPUTE_WORKER_PORT: ${COMPUTE_WORKER_PORT:-8081} + PORT: ${PORT:-8081} COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token} S3_PREFIX: ${S3_PREFIX:-openreader} COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-true} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index cc19593..4d3cbdd 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -422,8 +422,7 @@ async function createWorkerLoop(input: { } async function main(): Promise { - const platformPort = readIntEnv('PORT', 8081); - const port = readIntEnv('COMPUTE_WORKER_PORT', platformPort); + const port = readIntEnv('PORT', 8081); const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; const workerToken = requireEnv('COMPUTE_WORKER_TOKEN'); const natsUrl = requireEnv('NATS_URL'); diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index f5b0885..50bdfd4 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -42,7 +42,7 @@ Common optional: - `S3_FORCE_PATH_STYLE=true` (for many S3-compatible providers) - `S3_PREFIX=openreader` - `COMPUTE_WORKER_HOST=0.0.0.0` -- `COMPUTE_WORKER_PORT=8081` +- `PORT=8081` (local/manual; on Railway platform injects this) - `COMPUTE_LOG_FORMAT=pretty` (default) or `json` - `COMPUTE_PREWARM_MODELS=true` - `COMPUTE_JOBS_STREAM_MAX_BYTES=268435456` (256MB JetStream jobs stream cap) @@ -54,7 +54,10 @@ Set on the Next.js app server: ```env COMPUTE_MODE=worker -COMPUTE_WORKER_URL=http://:8081 +# Local worker example: +# COMPUTE_WORKER_URL=http://localhost:8081 +# Cloud worker example (Railway): +COMPUTE_WORKER_URL=https:// COMPUTE_WORKER_TOKEN= ``` @@ -94,7 +97,8 @@ Create a Railway service from: ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing ``` -Set the container port to `8081`, then enable public networking to get a worker URL. +Railway injects a dynamic `PORT` env var and routes traffic there. +Do not hardcode Railway ingress to `8081`; keep service networking enabled and use the public Railway URL. ### 3. Configure Railway worker environment variables @@ -102,7 +106,9 @@ Set these in the Railway worker service: ```env COMPUTE_WORKER_HOST=0.0.0.0 -COMPUTE_WORKER_PORT=8081 +# Local/manual only: +# PORT=8081 +# Railway: rely on injected PORT COMPUTE_WORKER_TOKEN= COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 COMPUTE_JOB_STATES_MAX_BYTES=67108864 @@ -125,6 +131,7 @@ Notes: - `NATS_CREDS` should be the full Synadia `.creds` file content, including begin/end markers. - Keep `COMPUTE_WORKER_TOKEN` identical between app server and worker. +- On Railway, leave `PORT` managed by the platform. - If your platform supports mounted files, you can use `NATS_CREDS_FILE` instead of `NATS_CREDS`. - `COMPUTE_JOBS_STREAM_MAX_BYTES` and `COMPUTE_JOB_STATES_MAX_BYTES` are optional; defaults are `268435456` (256MiB) and `67108864` (64MiB). From 8190b57ff2d8be8c770d165e87c3bb4923ac64c3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 20 May 2026 18:07:32 -0600 Subject: [PATCH 035/137] feat(documents): support parsed document retrieval by key and configurable worker wait timeout Add ability to fetch parsed documents using a stored S3 key, enabling more flexible blob access patterns. Update API route to prefer `parsedJsonKey` if present, falling back to legacy lookup. Introduce `COMPUTE_WORKER_WAIT_TIMEOUT_MS` environment variable for customizing worker job wait time, with a new default of 120 seconds. Update documentation and examples to reflect these changes. --- src/app/api/documents/[id]/parsed/route.ts | 26 +++++++++++++--------- src/lib/server/compute/worker.ts | 2 +- src/lib/server/documents/blobstore.ts | 14 ++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index b2ad9c4..23580ae 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -3,7 +3,12 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { getParsedDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { + getParsedDocumentBlob, + getParsedDocumentBlobByKey, + isMissingBlobError, + isValidDocumentId, +} from '@/lib/server/documents/blobstore'; import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; @@ -56,12 +61,14 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string id: documents.id, userId: documents.userId, parseStatus: documents.parseStatus, + parsedJsonKey: documents.parsedJsonKey, }) .from(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; parseStatus: string | null; + parsedJsonKey: string | null; }>; const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; @@ -97,7 +104,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } if (effectiveStatus !== 'ready') { - if (effectiveStatus === 'pending' || effectiveStatus === 'running') { + if (effectiveStatus === 'pending') { enqueueParsePdfJob({ documentId: id, userId: row.userId, @@ -108,7 +115,9 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } try { - const json = await getParsedDocumentBlob(id, testNamespace); + const json = row.parsedJsonKey?.trim() + ? await getParsedDocumentBlobByKey(row.parsedJsonKey) + : await getParsedDocumentBlob(id, testNamespace); let parsedDoc: ParsedPdfDocument | null = null; try { parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; @@ -117,16 +126,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } if (!hasAnyParsedBlocks(parsedDoc)) { - await db - .update(documents) - .set({ parseStatus: 'pending' }) - .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); - enqueueParsePdfJob({ + console.warn('[documents/parsed] parsed doc has no blocks', { documentId: id, userId: row.userId, - namespace: testNamespace, + parsedJsonKey: row.parsedJsonKey, }); - return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); } return new NextResponse(new Uint8Array(json), { @@ -171,12 +175,14 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string id: documents.id, userId: documents.userId, parseStatus: documents.parseStatus, + parsedJsonKey: documents.parsedJsonKey, }) .from(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; parseStatus: string | null; + parsedJsonKey: string | null; }>; const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index a2ae17f..500ad6f 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -19,7 +19,7 @@ class WorkerHttpError extends Error { } } -const DEFAULT_WAIT_TIMEOUT_MS = 45_000; +const DEFAULT_WAIT_TIMEOUT_MS = 120_000; const DEFAULT_RETRIES = 2; const POLL_INTERVAL_MS = 400; const POLL_MAX_INTERVAL_MS = 1_500; diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index d94d9f2..41aa97a 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -224,6 +224,20 @@ export async function getParsedDocumentBlob(id: string, namespace: string | null return bodyToBuffer(res.Body); } +export async function getParsedDocumentBlobByKey(key: string): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const trimmed = key.trim(); + if (!trimmed) throw new Error('Parsed document key is empty'); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: trimmed, + }), + ); + return bodyToBuffer(res.Body); +} + export async function putParsedDocumentBlob(id: string, body: Buffer, namespace: string | null): Promise { const cfg = getS3Config(); const client = getS3ProxyClient(); From a7b955e1ca021053d5434d095120d726a00b14f3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 09:05:26 -0600 Subject: [PATCH 036/137] refactor(worker): adopt operation-based API and opKey deduplication for compute flows Migrate worker and backend contract to an operation-centric model, introducing opKey-based deduplication for Whisper alignment and PDF layout parsing. Add new operation request/state types and update worker endpoints to accept generic operation requests and expose operation status via SSE. Refactor client and server logic to support operation flows. Update deployment docs, environment examples, and Dockerfile to reflect new architecture and dependency management. --- Dockerfile | 12 +- compute/core/src/contracts.ts | 30 + compute/worker/.env.example | 3 + compute/worker/src/server.ts | 761 +++++++++++++++------- docs-site/docs/deploy/compute-worker.md | 6 +- next.config.ts | 1 + src/app/(app)/pdf/[id]/page.tsx | 26 +- src/app/api/tts/segments/ensure/route.ts | 154 ++++- src/components/views/PDFViewer.tsx | 9 +- src/lib/server/compute/worker-contract.ts | 7 +- src/lib/server/compute/worker.ts | 532 +++++++++++++-- 11 files changed, 1247 insertions(+), 294 deletions(-) diff --git a/Dockerfile b/Dockerfile index 262bae0..ec8a7bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,19 +45,21 @@ FROM node:lts-alpine AS runner # ffmpeg is provided by ffmpeg-static from node_modules. RUN apk add --no-cache ca-certificates libreoffice-writer -# drizzle-kit is used by scripts/openreader-entrypoint.mjs for startup migrations. -RUN npm install -g drizzle-kit@0.31.10 +# Install pnpm and production deps from the repository lockfile. +RUN npm install -g pnpm@10.33.4 # App runtime directory WORKDIR /app -# Entry-point and migration scripts import dotenv directly. -RUN npm install --no-save dotenv@17.4.2 - # Copy standalone Next.js server and required static assets. COPY --from=app-builder /app/.next/standalone ./ COPY --from=app-builder /app/.next/static ./.next/static COPY --from=app-builder /app/public ./public +# Install runtime dependencies from lockfile (no Dockerfile version pin drift). +COPY --from=app-builder /app/package.json ./package.json +COPY --from=app-builder /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=app-builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +RUN CI=true pnpm install --prod --frozen-lockfile --config.confirmModulesPurge=false # Copy startup/migration scripts and migration files used by openreader-entrypoint. COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs diff --git a/compute/core/src/contracts.ts b/compute/core/src/contracts.ts index e97e672..0abe295 100644 --- a/compute/core/src/contracts.ts +++ b/compute/core/src/contracts.ts @@ -67,3 +67,33 @@ export interface WorkerJobStatusResponse { error?: WorkerJobErrorShape; timing?: WorkerJobTiming; } + +export type WorkerOperationKind = 'whisper_align' | 'pdf_layout'; + +export interface WhisperAlignOperationRequest { + kind: 'whisper_align'; + opKey: string; + payload: WhisperAlignJobRequest; +} + +export interface PdfLayoutOperationRequest { + kind: 'pdf_layout'; + opKey: string; + payload: PdfLayoutJobRequest; +} + +export type WorkerOperationRequest = WhisperAlignOperationRequest | PdfLayoutOperationRequest; + +export interface WorkerOperationState { + opId: string; + opKey: string; + kind: WorkerOperationKind; + jobId: string; + status: WorkerJobState; + queuedAt: number; + updatedAt: number; + startedAt?: number; + result?: Result; + error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; +} diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 766f0ce..fa18018 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -32,3 +32,6 @@ S3_FORCE_PATH_STYLE=true COMPUTE_PREWARM_MODELS=true COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 COMPUTE_JOB_STATES_MAX_BYTES=67108864 +# Optional stale window for reusing in-flight opKey entries before forcing a new attempt +# Default is max(30m, 4x max compute timeout); running jobs also refresh heartbeat every 5s +# COMPUTE_OP_STALE_MS=180000 diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 4d3cbdd..6712192 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import Fastify, { type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { @@ -30,8 +31,12 @@ import { type PdfLayoutJobResult, type WhisperAlignJobRequest, type WhisperAlignJobResult, - type WorkerJobStatusResponse, + type WorkerJobErrorShape, + type WorkerJobState, type WorkerJobTiming, + type WorkerOperationKind, + type WorkerOperationRequest, + type WorkerOperationState, } from '@openreader/compute-core/contracts'; import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; @@ -40,23 +45,40 @@ const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; const LAYOUT_JOBS_SUBJECT = 'jobs.layout'; const WHISPER_CONSUMER_NAME = 'compute_whisper'; const LAYOUT_CONSUMER_NAME = 'compute_layout'; -const JOB_STATES_BUCKET = 'job_states'; -const JOB_STATES_TTL_MS = 24 * 60 * 60 * 1000; +const COMPUTE_STATE_BUCKET = 'compute_state'; +const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000; const PULL_EXPIRES_MS = 1000; const LOOP_ERROR_BACKOFF_MS = 500; +const SSE_POLL_INTERVAL_MS = 400; +const RUNNING_HEARTBEAT_MS = 5000; const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; interface QueuedJob { jobId: string; + opId: string; + opKey: string; + kind: WorkerOperationKind; queuedAt: number; payload: TPayload; } -interface StoredJobState extends WorkerJobStatusResponse { +interface StoredJobState { + jobId: string; + opId: string; + opKey: string; + kind: WorkerOperationKind; + status: WorkerJobState; timestamp: number; startedAt?: number; updatedAt: number; + result?: Result; + error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; +} + +interface OpIndexEntry { + opId: string; } type JsonCodec = { @@ -209,6 +231,11 @@ function isAlreadyExistsError(error: unknown): boolean { return message.includes('already in use') || message.includes('already exists'); } +function isCasConflictError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return message.includes('wrong last sequence') || message.includes('key exists') || message.includes('wrong last'); +} + function createJsonCodec(): JsonCodec { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -222,6 +249,36 @@ function createJsonCodec(): JsonCodec { }; } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isTerminalStatus(status: WorkerJobState): boolean { + return status === 'succeeded' || status === 'failed'; +} + +function hashOpKey(opKey: string): string { + return createHash('sha256').update(opKey).digest('hex'); +} + +function opIndexKvKey(opKey: string): string { + return `op_index.${hashOpKey(opKey)}`; +} + +function opStateKvKey(opId: string): string { + return `op_state.${opId}`; +} + +function jobStateKvKey(jobId: string): string { + return `job_state.${jobId}`; +} + +function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { + if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; + const maybe = result as { parsedObjectKey?: unknown }; + return typeof maybe.parsedObjectKey === 'string' ? maybe.parsedObjectKey : undefined; +} + const alignSchema = z.object({ text: z.string().trim().min(1), lang: z.string().trim().min(1).max(16).optional(), @@ -235,24 +292,18 @@ const layoutSchema = z.object({ documentObjectKey: z.string().trim().min(1).max(2048), }); -async function putState( - kv: KV, - codec: JsonCodec>, - jobId: string, - state: StoredJobState, -): Promise { - await kv.put(jobId, codec.encode(state)); -} - -async function getState( - kv: KV, - codec: JsonCodec>, - jobId: string, -): Promise | null> { - const entry = await kv.get(jobId); - if (!entry || entry.operation !== 'PUT') return null; - return codec.decode(entry.value); -} +const operationCreateSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('whisper_align'), + opKey: z.string().trim().min(1).max(1024), + payload: alignSchema, + }), + z.object({ + kind: z.literal('pdf_layout'), + opKey: z.string().trim().min(1).max(1024), + payload: layoutSchema, + }), +]); async function ensureJetStreamResources( jsm: JetStreamManager, @@ -308,119 +359,6 @@ async function ensureJetStreamResources( ]); } -async function createWorkerLoop(input: { - consumer: Consumer; - kv: KV; - stateCodec: JsonCodec>; - jobCodec: JsonCodec>; - run: (payload: TPayload, queueWaitMs: number) => Promise; - maxAttempts: number; - logLabel: string; - shouldStop: () => boolean; - log: { - error: (obj: Record, msg: string) => void; - info: (obj: Record, msg: string) => void; - }; -}): Promise { - while (!input.shouldStop()) { - let msg: JsMsg | null = null; - try { - msg = await input.consumer.next({ expires: PULL_EXPIRES_MS }); - } catch (error) { - if (input.shouldStop()) return; - input.log.error({ error: toErrorMessage(error), worker: input.logLabel }, 'worker pull failed'); - await new Promise((resolve) => setTimeout(resolve, LOOP_ERROR_BACKOFF_MS)); - continue; - } - - if (!msg) continue; - - let decoded: QueuedJob | null = null; - try { - const job = input.jobCodec.decode(msg.data); - decoded = job; - const startedAt = Date.now(); - const queueWaitMs = safeDurationMs(job.queuedAt, startedAt); - - await putState(input.kv, input.stateCodec, job.jobId, { - status: 'running', - timestamp: job.queuedAt, - startedAt, - updatedAt: startedAt, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - }); - - const result = await input.run(job.payload, queueWaitMs ?? 0); - const resultTiming = result && typeof result === 'object' && 'timing' in result - ? (result as { timing?: WorkerJobTiming }).timing - : undefined; - - await putState(input.kv, input.stateCodec, job.jobId, { - status: 'succeeded', - timestamp: job.queuedAt, - startedAt, - updatedAt: Date.now(), - result, - ...(resultTiming ? { timing: resultTiming } : {}), - }); - - msg.ack(); - input.log.info({ worker: input.logLabel, jobId: job.jobId, timing: resultTiming }, 'job succeeded'); - } catch (error) { - const message = toErrorMessage(error); - const deliveryCount = msg.info.deliveryCount; - const hasRetriesLeft = deliveryCount < input.maxAttempts; - - if (decoded?.jobId) { - const now = Date.now(); - const queueWaitMs = safeDurationMs(decoded.queuedAt, now); - const state: StoredJobState = hasRetriesLeft - ? { - status: 'running', - timestamp: decoded.queuedAt, - updatedAt: now, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - } - : { - status: 'failed', - timestamp: decoded.queuedAt, - updatedAt: now, - error: { message }, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - }; - - await putState(input.kv, input.stateCodec, decoded.jobId, state).catch((stateError) => { - input.log.error({ - worker: input.logLabel, - jobId: decoded?.jobId, - error: toErrorMessage(stateError), - }, 'failed to persist failed state'); - }); - } - - if (hasRetriesLeft) { - msg.nak(); - input.log.error({ - worker: input.logLabel, - jobId: decoded?.jobId, - error: message, - deliveryCount, - maxAttempts: input.maxAttempts, - }, 'job failed, nacked for retry'); - } else { - msg.term(message); - input.log.error({ - worker: input.logLabel, - jobId: decoded?.jobId, - error: message, - deliveryCount, - maxAttempts: input.maxAttempts, - }, 'job failed, max attempts reached'); - } - } - } -} - async function main(): Promise { const port = readIntEnv('PORT', 8081); const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; @@ -435,6 +373,10 @@ async function main(): Promise { const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024); const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024); + const opStaleMs = readIntEnv( + 'COMPUTE_OP_STALE_MS', + Math.max(30 * 60_000, Math.max(whisperTimeoutMs, pdfTimeoutMs) * 4), + ); const connectOpts: any = { servers: natsUrl }; const natsCreds = process.env.NATS_CREDS?.trim(); @@ -445,7 +387,7 @@ async function main(): Promise { connectOpts.authenticator = credsAuthenticator(new TextEncoder().encode(natsCreds)); } else if (natsCredsFile) { console.log(`[compute-worker] Connecting to NATS using credentials file: ${natsCredsFile}`); - const { readFileSync } = require('fs'); + const { readFileSync } = await import('node:fs'); const credsData = readFileSync(natsCredsFile); connectOpts.authenticator = credsAuthenticator(credsData); } @@ -456,9 +398,9 @@ async function main(): Promise { await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, attempts, jobsStreamMaxBytes); - const kv = await new Kvm(js).create(JOB_STATES_BUCKET, { + const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, { history: 1, - ttl: JOB_STATES_TTL_MS, + ttl: COMPUTE_STATE_TTL_MS, max_bytes: jobStatesMaxBytes, }); @@ -503,10 +445,187 @@ async function main(): Promise { const app = Fastify({ logger: buildLoggerConfig() }); - const whisperStateCodec = createJsonCodec>(); - const layoutStateCodec = createJsonCodec>(); + const opIndexCodec = createJsonCodec(); + const opStateCodec = createJsonCodec>(); const whisperJobCodec = createJsonCodec>(); const layoutJobCodec = createJsonCodec>(); + const jobStateCodec = createJsonCodec>(); + + const putOpState = async (state: WorkerOperationState): Promise => { + await kv.put(opStateKvKey(state.opId), opStateCodec.encode(state)); + }; + + const getOpState = async (opId: string): Promise | null> => { + const entry = await kv.get(opStateKvKey(opId)); + if (!entry || entry.operation !== 'PUT') return null; + return opStateCodec.decode(entry.value); + }; + + const putJobState = async (state: StoredJobState): Promise => { + await kv.put(jobStateKvKey(state.jobId), jobStateCodec.encode(state)); + }; + + const publishQueuedJob = async ( + op: WorkerOperationState, + payload: WhisperAlignJobRequest | PdfLayoutJobRequest, + ): Promise => { + if (op.kind === 'whisper_align') { + await js.publish(WHISPER_JOBS_SUBJECT, whisperJobCodec.encode({ + jobId: op.jobId, + opId: op.opId, + opKey: op.opKey, + kind: 'whisper_align', + queuedAt: op.queuedAt, + payload: payload as WhisperAlignJobRequest, + })); + return; + } + + await js.publish(LAYOUT_JOBS_SUBJECT, layoutJobCodec.encode({ + jobId: op.jobId, + opId: op.opId, + opKey: op.opKey, + kind: 'pdf_layout', + queuedAt: op.queuedAt, + payload: payload as PdfLayoutJobRequest, + })); + }; + + const enqueueOrReuseOperation = async ( + req: WorkerOperationRequest, + ): Promise> => { + const opKey = req.opKey.trim(); + const indexKey = opIndexKvKey(opKey); + + for (let attemptNo = 0; attemptNo < 10; attemptNo += 1) { + const indexEntry = await kv.get(indexKey); + if (indexEntry && indexEntry.operation === 'PUT') { + const pointer = opIndexCodec.decode(indexEntry.value); + const current = await getOpState(pointer.opId); + + if (!current) { + await sleep(25); + continue; + } + + if (current && current.kind === req.kind) { + const ageMs = Date.now() - current.updatedAt; + if (current.status === 'succeeded') return current; + if ((current.status === 'queued' || current.status === 'running') && ageMs <= opStaleMs) { + return current; + } + } + + const now = Date.now(); + const replacement: WorkerOperationState = { + opId: crypto.randomUUID(), + opKey, + kind: req.kind, + jobId: crypto.randomUUID(), + status: 'queued', + queuedAt: now, + updatedAt: now, + }; + + try { + await kv.update(indexKey, opIndexCodec.encode({ opId: replacement.opId }), indexEntry.revision); + } catch (error) { + if (isCasConflictError(error)) continue; + throw error; + } + + await putOpState(replacement); + await putJobState({ + jobId: replacement.jobId, + opId: replacement.opId, + opKey: replacement.opKey, + kind: replacement.kind, + status: 'queued', + timestamp: now, + updatedAt: now, + }); + + try { + await publishQueuedJob(replacement, req.payload); + return replacement; + } catch (error) { + const failed: WorkerOperationState = { + ...replacement, + status: 'failed', + updatedAt: Date.now(), + error: { message: toErrorMessage(error) }, + }; + await putOpState(failed); + await putJobState({ + jobId: replacement.jobId, + opId: replacement.opId, + opKey: replacement.opKey, + kind: replacement.kind, + status: 'failed', + timestamp: replacement.queuedAt, + updatedAt: failed.updatedAt, + error: failed.error, + }); + return failed; + } + } + + const now = Date.now(); + const created: WorkerOperationState = { + opId: crypto.randomUUID(), + opKey, + kind: req.kind, + jobId: crypto.randomUUID(), + status: 'queued', + queuedAt: now, + updatedAt: now, + }; + + try { + await kv.create(indexKey, opIndexCodec.encode({ opId: created.opId })); + } catch (error) { + if (isCasConflictError(error)) continue; + throw error; + } + + await putOpState(created); + await putJobState({ + jobId: created.jobId, + opId: created.opId, + opKey: created.opKey, + kind: created.kind, + status: 'queued', + timestamp: now, + updatedAt: now, + }); + + try { + await publishQueuedJob(created, req.payload); + return created; + } catch (error) { + const failed: WorkerOperationState = { + ...created, + status: 'failed', + updatedAt: Date.now(), + error: { message: toErrorMessage(error) }, + }; + await putOpState(failed); + await putJobState({ + jobId: created.jobId, + opId: created.opId, + opKey: created.opKey, + kind: created.kind, + status: 'failed', + timestamp: created.queuedAt, + updatedAt: failed.updatedAt, + error: failed.error, + }); + return failed; + } + } + + throw new Error('Unable to reserve operation after repeated CAS conflicts'); + }; app.addHook('onRequest', async (request, reply) => { const path = request.url.split('?')[0] ?? request.url; @@ -532,8 +651,8 @@ async function main(): Promise { } }); - app.post('/align/whisper/jobs', async (request, reply) => { - const parsed = alignSchema.safeParse(request.body); + app.post('/ops', async (request, reply) => { + const parsed = operationCreateSchema.safeParse(request.body); if (!parsed.success) { reply.code(400); return { @@ -542,98 +661,74 @@ async function main(): Promise { }; } - const jobId = crypto.randomUUID(); - const queuedAt = Date.now(); - - await putState(kv, whisperStateCodec, jobId, { - status: 'queued', - timestamp: queuedAt, - updatedAt: queuedAt, - }); - - await js.publish(WHISPER_JOBS_SUBJECT, whisperJobCodec.encode({ - jobId, - queuedAt, - payload: parsed.data, - })); - + const op = await enqueueOrReuseOperation(parsed.data as WorkerOperationRequest); reply.code(202); - return { jobId }; + return op; }); - app.get('/align/whisper/jobs/:jobId', async (request, reply) => { - const params = z.object({ jobId: z.string().trim().min(1) }).safeParse(request.params); + app.get('/ops/:opId', async (request, reply) => { + const params = z.object({ opId: z.string().trim().min(1) }).safeParse(request.params); if (!params.success) { reply.code(400); - return { error: 'Invalid job id' }; + return { error: 'Invalid op id' }; } - const state = await getState(kv, whisperStateCodec, params.data.jobId); + const state = await getOpState(params.data.opId); if (!state) { reply.code(404); - return { error: 'Job not found' }; + return { error: 'Operation not found' }; } - const response: WorkerJobStatusResponse = { - status: state.status, - ...(state.result ? { result: state.result } : {}), - ...(state.error ? { error: state.error } : {}), - ...(state.timing ? { timing: state.timing } : {}), - }; - - return response; + return state; }); - app.post('/layout/pdf/jobs', async (request, reply) => { - const parsed = layoutSchema.safeParse(request.body); - if (!parsed.success) { - reply.code(400); - return { - error: 'Invalid request body', - issues: parsed.error.issues, - }; - } - - const jobId = crypto.randomUUID(); - const queuedAt = Date.now(); - - await putState(kv, layoutStateCodec, jobId, { - status: 'queued', - timestamp: queuedAt, - updatedAt: queuedAt, - }); - - await js.publish(LAYOUT_JOBS_SUBJECT, layoutJobCodec.encode({ - jobId, - queuedAt, - payload: parsed.data, - })); - - reply.code(202); - return { jobId }; - }); - - app.get('/layout/pdf/jobs/:jobId', async (request, reply) => { - const params = z.object({ jobId: z.string().trim().min(1) }).safeParse(request.params); + app.get('/ops/:opId/events', async (request, reply) => { + const params = z.object({ opId: z.string().trim().min(1) }).safeParse(request.params); if (!params.success) { reply.code(400); - return { error: 'Invalid job id' }; + return { error: 'Invalid op id' }; } - const state = await getState(kv, layoutStateCodec, params.data.jobId); - if (!state) { + const initial = await getOpState(params.data.opId); + if (!initial) { reply.code(404); - return { error: 'Job not found' }; + return { error: 'Operation not found' }; } - const response: WorkerJobStatusResponse = { - status: state.status, - ...(state.result ? { result: state.result } : {}), - ...(state.error ? { error: state.error } : {}), - ...(state.timing ? { timing: state.timing } : {}), + reply.hijack(); + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + + const writeSnapshot = (snapshot: WorkerOperationState): void => { + reply.raw.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`); }; - return response; + let closed = false; + request.raw.on('close', () => { + closed = true; + }); + + let current = initial; + writeSnapshot(current); + let signature = JSON.stringify(current); + + while (!closed && !isTerminalStatus(current.status)) { + await sleep(SSE_POLL_INTERVAL_MS); + const next = await getOpState(params.data.opId); + if (!next) break; + const nextSignature = JSON.stringify(next); + if (nextSignature !== signature) { + current = next; + signature = nextSignature; + writeSnapshot(current); + } + } + + if (!reply.raw.writableEnded) { + reply.raw.end(); + } }); const whisperConsumer = await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME); @@ -706,33 +801,247 @@ async function main(): Promise { }; }; + async function processMessage(input: { + msg: JsMsg; + codec: JsonCodec>; + run: (payload: TPayload, queueWaitMs: number) => Promise; + workerLabel: string; + }): Promise { + let decoded: QueuedJob | null = null; + let heartbeat: NodeJS.Timeout | null = null; + try { + decoded = input.codec.decode(input.msg.data); + const startedAt = Date.now(); + const queueWaitMs = safeDurationMs(decoded.queuedAt, startedAt); + + const runningState: WorkerOperationState = { + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + jobId: decoded.jobId, + status: 'running', + queuedAt: decoded.queuedAt, + startedAt, + updatedAt: startedAt, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }; + + await putOpState(runningState); + await putJobState({ + jobId: decoded.jobId, + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + status: 'running', + timestamp: decoded.queuedAt, + startedAt, + updatedAt: startedAt, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }); + + heartbeat = setInterval(() => { + const now = Date.now(); + const heartbeatState: WorkerOperationState = { + opId: decoded!.opId, + opKey: decoded!.opKey, + kind: decoded!.kind, + jobId: decoded!.jobId, + status: 'running', + queuedAt: decoded!.queuedAt, + startedAt, + updatedAt: now, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }; + void putOpState(heartbeatState).catch((stateError) => { + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist running heartbeat op state'); + }); + void putJobState({ + jobId: decoded!.jobId, + opId: decoded!.opId, + opKey: decoded!.opKey, + kind: decoded!.kind, + status: 'running', + timestamp: decoded!.queuedAt, + startedAt, + updatedAt: now, + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }).catch((stateError) => { + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist running heartbeat job state'); + }); + }, RUNNING_HEARTBEAT_MS); + + const result = await input.run(decoded.payload, queueWaitMs ?? 0); + const resultTiming = result && typeof result === 'object' && 'timing' in result + ? (result as { timing?: WorkerJobTiming }).timing + : undefined; + const now = Date.now(); + + const succeededState: WorkerOperationState = { + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + jobId: decoded.jobId, + status: 'succeeded', + queuedAt: decoded.queuedAt, + startedAt, + updatedAt: now, + result: result as WhisperAlignJobResult | PdfLayoutJobResult, + ...(resultTiming ? { timing: resultTiming } : {}), + }; + + await putOpState(succeededState); + await putJobState({ + jobId: decoded.jobId, + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + status: 'succeeded', + timestamp: decoded.queuedAt, + startedAt, + updatedAt: now, + result: result as WhisperAlignJobResult | PdfLayoutJobResult, + ...(resultTiming ? { timing: resultTiming } : {}), + }); + + input.msg.ack(); + app.log.info({ + worker: input.workerLabel, + opId: decoded.opId, + jobId: decoded.jobId, + resultRef: extractResultRef(decoded.kind, result), + timing: resultTiming, + }, 'job succeeded'); + } catch (error) { + const message = toErrorMessage(error); + const deliveryCount = input.msg.info.deliveryCount; + const hasRetriesLeft = deliveryCount < attempts; + + if (decoded) { + const now = Date.now(); + const queueWaitMs = safeDurationMs(decoded.queuedAt, now); + + const status: WorkerJobState = hasRetriesLeft ? 'running' : 'failed'; + const opState: WorkerOperationState = { + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + jobId: decoded.jobId, + status, + queuedAt: decoded.queuedAt, + updatedAt: now, + ...(status === 'failed' ? { error: { message } } : {}), + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }; + + await putOpState(opState).catch((stateError) => { + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist operation state'); + }); + + await putJobState({ + jobId: decoded.jobId, + opId: decoded.opId, + opKey: decoded.opKey, + kind: decoded.kind, + status, + timestamp: decoded.queuedAt, + updatedAt: now, + ...(status === 'failed' ? { error: { message } } : {}), + ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + }).catch((stateError) => { + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist job state'); + }); + } + + if (hasRetriesLeft) { + input.msg.nak(); + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: message, + deliveryCount, + maxAttempts: attempts, + }, 'job failed, nacked for retry'); + } else { + input.msg.term(message); + app.log.error({ + worker: input.workerLabel, + opId: decoded?.opId, + jobId: decoded?.jobId, + error: message, + deliveryCount, + maxAttempts: attempts, + }, 'job failed, max attempts reached'); + } + } finally { + if (heartbeat) clearInterval(heartbeat); + } + } + + async function createWorkerLoop(input: { + consumer: Consumer; + codec: JsonCodec>; + run: (payload: TPayload, queueWaitMs: number) => Promise; + workerLabel: string; + }): Promise { + while (!stopping) { + let msg: JsMsg | null = null; + try { + msg = await input.consumer.next({ expires: PULL_EXPIRES_MS }); + } catch (error) { + if (stopping) return; + app.log.error({ error: toErrorMessage(error), worker: input.workerLabel }, 'worker pull failed'); + await sleep(LOOP_ERROR_BACKOFF_MS); + continue; + } + + if (!msg) continue; + await processMessage({ + msg, + codec: input.codec, + run: input.run, + workerLabel: input.workerLabel, + }); + } + } + const workerLoops: Promise[] = []; for (let i = 0; i < whisperConcurrency; i += 1) { workerLoops.push(createWorkerLoop({ consumer: whisperConsumer, - kv, - stateCodec: whisperStateCodec, - jobCodec: whisperJobCodec, + codec: whisperJobCodec, run: runWhisper, - maxAttempts: attempts, - logLabel: `whisper-${i + 1}`, - shouldStop: () => stopping, - log: app.log, + workerLabel: `whisper-${i + 1}`, })); } for (let i = 0; i < pdfConcurrency; i += 1) { workerLoops.push(createWorkerLoop({ consumer: layoutConsumer, - kv, - stateCodec: layoutStateCodec, - jobCodec: layoutJobCodec, + codec: layoutJobCodec, run: runLayout, - maxAttempts: attempts, - logLabel: `layout-${i + 1}`, - shouldStop: () => stopping, - log: app.log, + workerLabel: `layout-${i + 1}`, })); } diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 50bdfd4..454f704 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -7,10 +7,10 @@ Use this guide for `COMPUTE_MODE=worker` deployments where heavy compute runs ou The compute worker handles: -- Whisper word alignment (`/align/whisper/jobs`) -- PDF layout parsing (`/layout/pdf/jobs`) +- Whisper word alignment operations +- PDF layout parsing operations -The app server enqueues jobs and polls status. Queue durability and retries are backed by NATS JetStream WorkQueue consumers and NATS KV. +The app server submits operations to `POST /ops`, reuses in-flight work via required `opKey`, and consumes status updates via `GET /ops/:opId/events` (SSE). Queue durability and retries are backed by NATS JetStream WorkQueue consumers and NATS KV. ## Published image diff --git a/next.config.ts b/next.config.ts index 18fc239..0cbfb73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -25,6 +25,7 @@ const isVercel = process.env.VERCEL === '1'; const serverExternalPackages = [ '@napi-rs/canvas', 'better-sqlite3', + 'ffmpeg-static', ...(computeLocal ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), ]; diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index bad2b1e..9646da7 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -4,7 +4,6 @@ import dynamic from 'next/dynamic'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; -import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/documents/DocumentSettings'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; @@ -27,7 +26,7 @@ const PDFViewer = dynamic( () => import('@/components/views/PDFViewer').then((module) => module.PDFViewer), { ssr: false, - loading: () => + loading: () => null } ); @@ -55,6 +54,7 @@ export default function PDFViewerPage() { const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isPdfViewerReady, setIsPdfViewerReady] = useState(false); const [zoomLevel, setZoomLevel] = useState(100); const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); @@ -68,6 +68,7 @@ export default function PDFViewerPage() { useEffect(() => { setIsLoading(true); + setIsPdfViewerReady(false); setError(null); setActiveSidebar(null); inFlightDocIdRef.current = null; @@ -303,12 +304,21 @@ export default function PDFViewerPage() {
} /> -
- {isLoading || !isParseReady ? ( - renderPdfStatusLoader() - ) : ( - - )} +
+ {isParseReady ? ( +
+ setIsPdfViewerReady(true)} + pdfState={pdfState} + /> +
+ ) : null} + {isLoading || !isParseReady || !isPdfViewerReady ? ( +
+ {renderPdfStatusLoader()} +
+ ) : null}
{canExportAudiobook && ( | null = null; const getComputeBackend = async () => { if (!computeBackendPromise) computeBackendPromise = getCompute(); @@ -325,6 +329,9 @@ export async function POST(request: NextRequest) { }; for (const segment of normalized) { + const segmentStartedAt = Date.now(); + const stageTimings: Record = {}; + let failedStage = 'unknown'; const locatorProjection = projectSegmentLocator(segment.locator); const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim() ? segment.original.segmentKey.trim() @@ -377,11 +384,13 @@ export async function POST(request: NextRequest) { // previously unavailable, retry alignment using the current segment text. if (!alignment) { try { + const alignStartedAt = Date.now(); const computeBackend = await getComputeBackend(); const aligned = (await computeBackend.alignWords({ audioObjectKey: existing.audioKey, text: segment.text, })).alignments; + stageTimings.selfHealAlignMs = Date.now() - alignStartedAt; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; if (alignment) { @@ -398,6 +407,7 @@ export async function POST(request: NextRequest) { } } catch (alignError) { console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', { + requestId, segmentId: segment.segmentId, error: alignError instanceof Error ? alignError.message : String(alignError), }); @@ -504,7 +514,92 @@ export async function POST(request: NextRequest) { await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId); } + const [currentVariant] = await db + .select({ + status: ttsSegmentVariants.status, + updatedAt: ttsSegmentVariants.updatedAt, + error: ttsSegmentVariants.error, + audioKey: ttsSegmentVariants.audioKey, + }) + .from(ttsSegmentVariants) + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )) + .limit(1); + + if (!currentVariant) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + + if (currentVariant.status === 'generating') { + const lastUpdatedAt = Number(currentVariant.updatedAt ?? 0); + const isFresh = lastUpdatedAt > 0 && (Date.now() - lastUpdatedAt) < GENERATING_STALE_MS; + if (isFresh) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + } + + if (currentVariant.status === 'pending' || currentVariant.status === 'error' || currentVariant.status === 'generating') { + const expectedUpdatedAt = Number(currentVariant.updatedAt ?? 0); + const [claim] = await db + .update(ttsSegmentVariants) + .set({ + status: 'generating', + error: null, + updatedAt: Date.now(), + }) + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + eq(ttsSegmentVariants.status, currentVariant.status), + eq(ttsSegmentVariants.updatedAt, expectedUpdatedAt), + )) + .returning({ + status: ttsSegmentVariants.status, + }); + + if (!claim) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + } + try { + failedStage = 'tts.generate'; + const ttsStartedAt = Date.now(); const ttsBuffer = await generateTTSBuffer({ text: segment.text, voice: effectiveSettings.voice, @@ -517,33 +612,47 @@ export async function POST(request: NextRequest) { baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal); + stageTimings.generateTtsMs = Date.now() - ttsStartedAt; + failedStage = 's3.put_audio'; + const putStartedAt = Date.now(); await putTtsSegmentAudioObject(audioKey, ttsBuffer); + stageTimings.putAudioMs = Date.now() - putStartedAt; let persistedBuffer = ttsBuffer; if (persistedBuffer.byteLength === 0) { + failedStage = 's3.get_audio_after_empty_put'; + const getStartedAt = Date.now(); persistedBuffer = await getTtsSegmentAudioObject(audioKey); + stageTimings.getAudioAfterEmptyPutMs = Date.now() - getStartedAt; } + failedStage = 'audio.probe_duration'; + const probeStartedAt = Date.now(); const durationMs = await probeAudioDurationMsFromBuffer(persistedBuffer, request.signal); + stageTimings.probeDurationMs = Date.now() - probeStartedAt; let alignment: TTSSegmentManifestItem['alignment'] = null; try { - const whisperBytes = Uint8Array.from(persistedBuffer); + failedStage = 'whisper.align'; + const alignStartedAt = Date.now(); const computeBackend = await getComputeBackend(); const aligned = (await computeBackend.alignWords({ - audioBuffer: whisperBytes.buffer, audioObjectKey: audioKey, text: segment.text, })).alignments; + stageTimings.whisperAlignMs = Date.now() - alignStartedAt; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; } catch (alignError) { console.warn('Whisper alignment unavailable for segment; continuing without word highlights.', { + requestId, segmentId: segment.segmentId, error: alignError instanceof Error ? alignError.message : String(alignError), }); alignment = null; } + failedStage = 'db.mark_completed'; + const markCompletedStartedAt = Date.now(); await db .update(ttsSegmentVariants) .set({ @@ -557,12 +666,16 @@ export async function POST(request: NextRequest) { eq(ttsSegmentVariants.segmentId, segment.segmentId), eq(ttsSegmentVariants.userId, scope.storageUserId), )); + stageTimings.markCompletedMs = Date.now() - markCompletedStartedAt; + failedStage = 'resolve.audio_urls'; + const resolveUrlsStartedAt = Date.now(); const audioUrls = await resolveSegmentAudioUrls({ documentId: parsed.documentId, segmentId: segment.segmentId, audioKey, }); + stageTimings.resolveAudioUrlsMs = Date.now() - resolveUrlsStartedAt; manifest.push({ segmentId: segment.segmentId, @@ -586,6 +699,19 @@ export async function POST(request: NextRequest) { : upstreamStatus && upstreamStatus >= 500 ? 'UPSTREAM_TTS_ERROR' : 'TTS_SEGMENT_GENERATION_FAILED'; + console.error('[tts-segments/ensure] segment failed', { + requestId, + documentId: parsed.documentId, + segmentId: segment.segmentId, + failedStage, + elapsedMs: Date.now() - segmentStartedAt, + stageTimings, + aborted, + upstreamStatus, + retryAfterSeconds, + errorCode, + message, + }); await db .update(ttsSegmentVariants) .set({ @@ -619,11 +745,33 @@ export async function POST(request: NextRequest) { detail: message, ...(typeof upstreamStatus === 'number' ? { upstreamStatus } : {}), ...(typeof retryAfterSeconds === 'number' ? { retryAfterSeconds } : {}), - }, + }, }); } } + const completedCount = manifest.filter((s) => s.status === 'completed').length; + const pendingCount = manifest.filter((s) => s.status === 'pending').length; + const errorItems = manifest.filter((s) => s.status === 'error'); + if (errorItems.length > 0) { + console.error('[tts-segments/ensure] partial result', { + requestId, + documentId: parsed.documentId, + total: manifest.length, + completedCount, + pendingCount, + errorCount: errorItems.length, + elapsedMs: Date.now() - requestStartedAt, + errors: errorItems.slice(0, 5).map((item) => ({ + segmentId: item.segmentId, + code: item.error?.code ?? null, + detail: item.error?.detail ?? null, + upstreamStatus: item.error?.upstreamStatus ?? null, + retryAfterSeconds: item.error?.retryAfterSeconds ?? null, + })), + }); + } + const response = NextResponse.json({ documentId: parsed.documentId, segments: manifest, diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 8bbecec..947f9c7 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -5,7 +5,6 @@ import { Document, Page } from 'react-pdf'; import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; -import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; @@ -14,6 +13,7 @@ import type { ParsedPdfBlock, ParsedPdfPage } from '@/types/parsed-pdf'; interface PDFViewerProps { zoomLevel: number; + onDocumentReady?: () => void; pdfState: Pick< PdfDocumentState, | 'highlightPattern' @@ -36,7 +36,7 @@ interface PDFOnLinkClickArgs { dest?: Dest; } -export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { +export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerProps) { const containerRef = useRef(null); const [isPageRendering, setIsPageRendering] = useState(false); const scaleRef = useRef(1); @@ -495,11 +495,12 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { > } - noData={} + loading={null} + noData={null} file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); + onDocumentReady?.(); }} onItemClick={(args: PDFOnLinkClickArgs) => { if (args?.pageNumber) { diff --git a/src/lib/server/compute/worker-contract.ts b/src/lib/server/compute/worker-contract.ts index a93c984..19e6bc5 100644 --- a/src/lib/server/compute/worker-contract.ts +++ b/src/lib/server/compute/worker-contract.ts @@ -1,11 +1,14 @@ export { - ALIGN_QUEUE_NAME, - PDF_LAYOUT_QUEUE_NAME, type PdfLayoutJobRequest, type PdfLayoutJobResult, + type PdfLayoutOperationRequest, type WhisperAlignJobRequest, type WhisperAlignJobResult, + type WhisperAlignOperationRequest, type WorkerJobErrorShape, + type WorkerOperationKind, + type WorkerOperationRequest, + type WorkerOperationState, type WorkerJobState, type WorkerJobTiming, type WorkerJobStatusResponse, diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 500ad6f..55c9197 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -1,10 +1,11 @@ +import { createHash, randomUUID } from 'node:crypto'; import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; import type { PdfLayoutJobRequest, PdfLayoutJobResult, WhisperAlignJobRequest, WhisperAlignJobResult, - WorkerJobStatusResponse, + WorkerOperationState, } from '@/lib/server/compute/worker-contract'; class WorkerHttpError extends Error { @@ -21,8 +22,72 @@ class WorkerHttpError extends Error { const DEFAULT_WAIT_TIMEOUT_MS = 120_000; const DEFAULT_RETRIES = 2; -const POLL_INTERVAL_MS = 400; -const POLL_MAX_INTERVAL_MS = 1_500; +const LOG_PREFIX = '[compute-worker-client]'; +const MAX_LOG_DETAIL_CHARS = 600; +const LOG_EVENTS = new Set([ + 'align.request.failed', + 'align.request.attempt_error', + 'pdf_layout.request.failed', + 'pdf_layout.request.attempt_error', + 'http.request.failed', + 'sse.wait.http_failed', + 'sse.wait.ended_without_terminal', + 'sse.wait.failed', +]); + +type WorkerLogLevel = 'info' | 'warn' | 'error'; + +function truncateForLog(value: string, maxChars = MAX_LOG_DETAIL_CHARS): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}...`; +} + +function errorToLog(error: unknown): Record { + if (error instanceof WorkerHttpError) { + return { + name: error.name, + message: error.message, + status: error.status, + retryAfterMs: error.retryAfterMs, + stack: error.stack, + }; + } + if (error instanceof Error) { + return { name: error.name, message: error.message, stack: error.stack }; + } + return { message: String(error) }; +} + +function logWorker(level: WorkerLogLevel, event: string, fields: Record): void { + if (!LOG_EVENTS.has(event)) return; + + const payload = { + ts: new Date().toISOString(), + event, + ...fields, + }; + const line = `${LOG_PREFIX} ${JSON.stringify(payload)}`; + if (level === 'error') { + console.error(line); + return; + } + if (level === 'warn') { + console.warn(line); + return; + } + console.info(line); +} + +function opSummary(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const record = value as Record; + const summary: Record = {}; + if (typeof record.opId === 'string') summary.opId = record.opId; + if (typeof record.status === 'string') summary.status = record.status; + if (typeof record.jobId === 'string') summary.jobId = record.jobId; + if (typeof record.updatedAt === 'string') summary.updatedAt = record.updatedAt; + return summary; +} function readRequiredEnv(name: string): string { const value = process.env[name]?.trim(); @@ -79,19 +144,88 @@ function shouldRetry(error: unknown): boolean { return false; } -async function withRetries(attempts: number, operation: () => Promise): Promise { +function isTerminalStatus(status: string): boolean { + return status === 'succeeded' || status === 'failed'; +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +function buildWhisperOpKey(input: WhisperAlignInput): string { + const cacheKey = input.cacheKey?.trim(); + if (cacheKey) { + return `whisper_align|v1|cache|${cacheKey}|${input.audioObjectKey}`; + } + return [ + 'whisper_align', + 'v1', + input.audioObjectKey, + input.lang ?? '', + sha256Hex(input.text), + ].join('|'); +} + +function buildPdfOpKey(input: PdfLayoutInput): string { + return [ + 'pdf_layout', + 'v1', + input.documentId, + input.namespace ?? '', + input.documentObjectKey ?? '', + ].join('|'); +} + +function extractSsePayload(frame: string): string | null { + const dataLines: string[] = []; + const normalized = frame.replace(/\r\n/g, '\n'); + for (const line of normalized.split('\n')) { + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + if (dataLines.length === 0) return null; + return dataLines.join('\n'); +} + +type RetryMeta = { + attempt: number, + maxAttempts: number, + willRetry: boolean, + delayMs: number | null, + error: unknown, +}; + +async function withRetries( + attempts: number, + operation: (attempt: number) => Promise, + onAttemptError?: (meta: RetryMeta) => void, +): Promise { let lastError: unknown = null; - for (let attempt = 0; attempt < attempts; attempt += 1) { + for (let attemptIndex = 0; attemptIndex < attempts; attemptIndex += 1) { + const attempt = attemptIndex + 1; try { - return await operation(); + return await operation(attempt); } catch (error) { lastError = error; - if (attempt === attempts - 1 || !shouldRetry(error)) break; - if (error instanceof WorkerHttpError && typeof error.retryAfterMs === 'number') { - await sleep(error.retryAfterMs); - } else { - await sleep((attempt + 1) * 250); + const willRetry = attemptIndex < attempts - 1 && shouldRetry(error); + let delayMs: number | null = null; + if (willRetry) { + if (error instanceof WorkerHttpError && typeof error.retryAfterMs === 'number') { + delayMs = error.retryAfterMs; + } else { + delayMs = attempt * 250; + } } + onAttemptError?.({ + attempt, + maxAttempts: attempts, + willRetry, + delayMs, + error, + }); + if (!willRetry) break; + await sleep(delayMs ?? 0); } } throw lastError instanceof Error ? lastError : new Error('Unknown worker compute failure'); @@ -121,15 +255,78 @@ export class WorkerComputeBackend implements ComputeBackend { cacheKey: input.cacheKey, audioObjectKey: input.audioObjectKey, }; - - return withRetries(this.retries, async () => { - const { jobId } = await this.requestJson<{ jobId: string }>('POST', '/align/whisper/jobs', payload); - const status = await this.waitForJob(`/align/whisper/jobs/${encodeURIComponent(jobId)}`); - if (status.status !== 'succeeded' || !status.result) { - throw new Error(status.error?.message || 'Whisper worker job did not complete'); - } - return { alignments: status.result.alignments }; + const traceId = randomUUID(); + const opKey = buildWhisperOpKey(input); + const opKeyHash = sha256Hex(opKey).slice(0, 16); + const startedAt = Date.now(); + logWorker('info', 'align.request.start', { + traceId, + kind: 'whisper_align', + opKeyHash, + audioObjectKey: input.audioObjectKey, + cacheKey: input.cacheKey ?? null, + lang: input.lang ?? null, + textLength: input.text.length, + waitTimeoutMs: this.waitTimeoutMs, + maxRetries: this.retries, }); + + try { + const result = await withRetries(this.retries, async (attempt) => { + const op = await this.requestJson>('POST', '/ops', { + kind: 'whisper_align', + opKey, + payload, + }, { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + }); + + const final = isTerminalStatus(op.status) + ? op + : await this.waitForOperation(op.opId, { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + }); + + if (final.status !== 'succeeded' || !final.result) { + throw new Error(final.error?.message || 'Whisper worker operation did not complete'); + } + return { alignments: final.result.alignments }; + }, ({ attempt, maxAttempts, willRetry, delayMs, error }) => { + logWorker(willRetry ? 'warn' : 'error', 'align.request.attempt_error', { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + maxAttempts, + willRetry, + delayMs, + error: errorToLog(error), + }); + }); + + logWorker('info', 'align.request.succeeded', { + traceId, + kind: 'whisper_align', + opKeyHash, + durationMs: Date.now() - startedAt, + }); + return result; + } catch (error) { + logWorker('error', 'align.request.failed', { + traceId, + kind: 'whisper_align', + opKeyHash, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } } async parsePdfLayout(input: PdfLayoutInput) { @@ -141,27 +338,109 @@ export class WorkerComputeBackend implements ComputeBackend { namespace: input.namespace ?? null, documentObjectKey: input.documentObjectKey, }; - return withRetries(this.retries, async () => { - const { jobId } = await this.requestJson<{ jobId: string }>('POST', '/layout/pdf/jobs', payload); - const status = await this.waitForJob(`/layout/pdf/jobs/${encodeURIComponent(jobId)}`); - if (status.status !== 'succeeded' || !status.result) { - throw new Error(status.error?.message || 'PDF layout worker job did not complete'); - } - if (status.result.parsedObjectKey) { - return { parsedObjectKey: status.result.parsedObjectKey }; - } - if (status.result.parsed) { - return { parsed: status.result.parsed }; - } - throw new Error('PDF layout worker job completed without parsed output'); + const traceId = randomUUID(); + const opKey = buildPdfOpKey(input); + const opKeyHash = sha256Hex(opKey).slice(0, 16); + const startedAt = Date.now(); + logWorker('info', 'pdf_layout.request.start', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + namespace: input.namespace ?? null, + documentObjectKey: input.documentObjectKey, + waitTimeoutMs: this.waitTimeoutMs, + maxRetries: this.retries, }); + + try { + const result = await withRetries(this.retries, async (attempt) => { + const op = await this.requestJson>('POST', '/ops', { + kind: 'pdf_layout', + opKey, + payload, + }, { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + }); + + const final = isTerminalStatus(op.status) + ? op + : await this.waitForOperation(op.opId, { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + }); + + if (final.status !== 'succeeded' || !final.result) { + throw new Error(final.error?.message || 'PDF layout worker operation did not complete'); + } + if (final.result.parsedObjectKey) { + return { parsedObjectKey: final.result.parsedObjectKey }; + } + if (final.result.parsed) { + return { parsed: final.result.parsed }; + } + throw new Error('PDF layout worker operation completed without parsed output'); + }, ({ attempt, maxAttempts, willRetry, delayMs, error }) => { + logWorker(willRetry ? 'warn' : 'error', 'pdf_layout.request.attempt_error', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + maxAttempts, + willRetry, + delayMs, + error: errorToLog(error), + }); + }); + + logWorker('info', 'pdf_layout.request.succeeded', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + durationMs: Date.now() - startedAt, + }); + return result; + } catch (error) { + logWorker('error', 'pdf_layout.request.failed', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } } - private async requestJson(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + private async requestJson( + method: 'GET' | 'POST', + path: string, + body?: unknown, + context: Record = {}, + ): Promise { + const startedAt = Date.now(); + const traceId = typeof context.traceId === 'string' ? context.traceId : randomUUID(); + logWorker('info', 'http.request.start', { + ...context, + traceId, + method, + path, + }); const res = await fetch(`${this.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${this.token}`, + 'x-openreader-trace-id': traceId, ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}), }, ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), @@ -170,6 +449,16 @@ export class WorkerComputeBackend implements ComputeBackend { if (!res.ok) { const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); const detail = await res.text().catch(() => ''); + logWorker(res.status >= 500 ? 'warn' : 'error', 'http.request.failed', { + ...context, + traceId, + method, + path, + status: res.status, + retryAfterMs, + durationMs: Date.now() - startedAt, + detail: truncateForLog(detail), + }); throw new WorkerHttpError( `Worker request failed (${method} ${path}): ${res.status}${detail ? ` ${detail}` : ''}`, res.status, @@ -177,18 +466,175 @@ export class WorkerComputeBackend implements ComputeBackend { ); } - return res.json() as Promise; + const parsed = await res.json() as T; + const operationSummary = opSummary(parsed); + logWorker('info', 'http.request.succeeded', { + ...context, + traceId, + method, + path, + httpStatus: res.status, + durationMs: Date.now() - startedAt, + ...operationSummary, + }); + return parsed; } - private async waitForJob(path: string): Promise> { - const started = Date.now(); - let interval = POLL_INTERVAL_MS; - while ((Date.now() - started) < this.waitTimeoutMs) { - const status = await this.requestJson>('GET', path); - if (status.status === 'succeeded' || status.status === 'failed') return status; - await sleep(interval); - interval = Math.min(POLL_MAX_INTERVAL_MS, Math.floor(interval * 1.5)); + private async waitForOperation( + opId: string, + context: Record = {}, + ): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.waitTimeoutMs); + const startedAt = Date.now(); + const traceId = typeof context.traceId === 'string' ? context.traceId : randomUUID(); + logWorker('info', 'sse.wait.start', { + ...context, + traceId, + opId, + waitTimeoutMs: this.waitTimeoutMs, + }); + + try { + const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'text/event-stream', + 'x-openreader-trace-id': traceId, + }, + signal: controller.signal, + }); + + if (!res.ok) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const detail = await res.text().catch(() => ''); + logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', { + ...context, + traceId, + opId, + status: res.status, + retryAfterMs, + durationMs: Date.now() - startedAt, + detail: truncateForLog(detail), + }); + throw new WorkerHttpError( + `Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${detail ? ` ${detail}` : ''}`, + res.status, + retryAfterMs, + ); + } + + if (!res.body) { + logWorker('error', 'sse.wait.no_body', { + ...context, + traceId, + opId, + durationMs: Date.now() - startedAt, + }); + throw new Error('Worker operation stream response has no body'); + } + + logWorker('info', 'sse.wait.connected', { + ...context, + traceId, + opId, + status: res.status, + }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let latest: WorkerOperationState | null = null; + let eventCount = 0; + let lastStatus: string | null = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const frameEnd = buffer.indexOf('\n\n'); + if (frameEnd < 0) break; + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + + const payload = extractSsePayload(frame); + if (!payload) continue; + + let snapshot: WorkerOperationState; + try { + snapshot = JSON.parse(payload) as WorkerOperationState; + } catch { + logWorker('warn', 'sse.wait.json_parse_skipped', { + ...context, + traceId, + opId, + sample: truncateForLog(payload), + }); + continue; + } + + eventCount += 1; + latest = snapshot; + if (snapshot.status !== lastStatus) { + lastStatus = snapshot.status; + logWorker('info', 'sse.wait.status', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + jobId: snapshot.jobId ?? null, + updatedAt: snapshot.updatedAt ?? null, + }); + } + if (isTerminalStatus(snapshot.status)) { + logWorker('info', 'sse.wait.terminal', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + durationMs: Date.now() - startedAt, + }); + return snapshot; + } + } + } + + if (latest && isTerminalStatus(latest.status)) { + logWorker('info', 'sse.wait.terminal_after_close', { + ...context, + traceId, + opId, + eventCount, + status: latest.status, + durationMs: Date.now() - startedAt, + }); + return latest; + } + + logWorker('error', 'sse.wait.ended_without_terminal', { + ...context, + traceId, + opId, + eventCount, + latestStatus: latest?.status ?? null, + durationMs: Date.now() - startedAt, + }); + throw new Error(`Operation stream ended before terminal state for op ${opId}`); + } catch (error) { + logWorker('error', 'sse.wait.failed', { + ...context, + traceId, + opId, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } finally { + clearTimeout(timeout); } - throw new Error(`Timed out waiting for worker job after ${this.waitTimeoutMs}ms`); } } From 4497f610c04f60103bb898142d066bfcebaf1c01 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 10:27:16 -0600 Subject: [PATCH 037/137] feat(pdf): implement granular PDF parse progress tracking and migrate to parseState Introduce detailed progress reporting for PDF layout parsing, exposing phase and page-level updates to clients. Replace legacy parseStatus with a structured parseState field in the database schema, updating all relevant backend and API logic. Add SSE endpoint for real-time parse progress updates. Update client hooks and UI to reflect granular progress and improve user feedback during document parsing. Includes migration scripts and new parse-state utility module. --- compute/core/src/contracts.ts | 10 + compute/core/src/local-runtime.ts | 6 + compute/core/src/pdf-layout/parsePdf.ts | 14 + compute/worker/src/server.ts | 82 +- drizzle/postgres/0007_pdf_parse_progress.sql | 2 + drizzle/postgres/meta/0007_snapshot.json | 1831 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0007_pdf_parse_progress.sql | 2 + drizzle/sqlite/meta/0007_snapshot.json | 1693 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + src/app/(app)/pdf/[id]/page.tsx | 112 +- src/app/(app)/pdf/[id]/usePdfDocument.ts | 69 +- .../api/documents/[id]/parsed/events/route.ts | 191 ++ src/app/api/documents/[id]/parsed/route.ts | 68 +- src/app/api/documents/route.ts | 27 +- src/db/schema_postgres.ts | 2 +- src/db/schema_sqlite.ts | 2 +- src/lib/client/api/documents.ts | 38 +- src/lib/server/compute/local.ts | 13 +- src/lib/server/compute/types.ts | 8 +- src/lib/server/compute/worker-contract.ts | 2 + src/lib/server/compute/worker.ts | 10 +- src/lib/server/documents/parse-state.ts | 72 + src/lib/server/jobs/parsePdfJob.ts | 47 +- src/types/parsed-pdf.ts | 8 + tests/unit/transfer-user-documents.spec.ts | 2 +- 26 files changed, 4226 insertions(+), 99 deletions(-) create mode 100644 drizzle/postgres/0007_pdf_parse_progress.sql create mode 100644 drizzle/postgres/meta/0007_snapshot.json create mode 100644 drizzle/sqlite/0007_pdf_parse_progress.sql create mode 100644 drizzle/sqlite/meta/0007_snapshot.json create mode 100644 src/app/api/documents/[id]/parsed/events/route.ts create mode 100644 src/lib/server/documents/parse-state.ts diff --git a/compute/core/src/contracts.ts b/compute/core/src/contracts.ts index 0abe295..bb0a8d7 100644 --- a/compute/core/src/contracts.ts +++ b/compute/core/src/contracts.ts @@ -61,6 +61,15 @@ export interface WorkerJobTiming { computeMs?: number; } +export type PdfLayoutProgressPhase = 'infer' | 'merge'; + +export interface PdfLayoutProgress { + totalPages: number; + pagesParsed: number; + currentPage?: number; + phase: PdfLayoutProgressPhase; +} + export interface WorkerJobStatusResponse { status: WorkerJobState; result?: Result; @@ -96,4 +105,5 @@ export interface WorkerOperationState { result?: Result; error?: WorkerJobErrorShape; timing?: WorkerJobTiming; + progress?: PdfLayoutProgress; } diff --git a/compute/core/src/local-runtime.ts b/compute/core/src/local-runtime.ts index 5a794b8..15fe92e 100644 --- a/compute/core/src/local-runtime.ts +++ b/compute/core/src/local-runtime.ts @@ -25,10 +25,16 @@ export async function runWhisperAlignmentFromAudioBuffer(input: { export async function runPdfLayoutFromPdfBuffer(input: { documentId: string; pdfBytes: ArrayBuffer; + onPageParsed?: (input: { + pageNumber: number; + totalPages: number; + pageMs: number; + }) => void | Promise; }) { const parsed = await parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes, + onPageParsed: input.onPageParsed, }); return { parsed }; } diff --git a/compute/core/src/pdf-layout/parsePdf.ts b/compute/core/src/pdf-layout/parsePdf.ts index 74c9dfe..0207b8f 100644 --- a/compute/core/src/pdf-layout/parsePdf.ts +++ b/compute/core/src/pdf-layout/parsePdf.ts @@ -11,6 +11,11 @@ import { renderPage } from './renderPage'; interface ParsePdfInput { documentId: string; pdfBytes: ArrayBuffer; + onPageParsed?: (input: { + pageNumber: number; + totalPages: number; + pageMs: number; + }) => void | Promise; } const LAYOUT_RENDER_SCALE = 1.5; @@ -80,6 +85,7 @@ export async function parsePdf(input: ParsePdfInput): Promise let sawText = false; for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const pageStartedAt = Date.now(); const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.0 }); const textContent = await page.getTextContent(); @@ -143,6 +149,14 @@ export async function parsePdf(input: ParsePdfInput): Promise height: viewport.height, blocks, }); + + if (input.onPageParsed) { + await input.onPageParsed({ + pageNumber, + totalPages: pdf.numPages, + pageMs: Date.now() - pageStartedAt, + }); + } } if (!sawText) { diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 6712192..36c818b 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -37,6 +37,7 @@ import { type WorkerOperationKind, type WorkerOperationRequest, type WorkerOperationState, + type PdfLayoutProgress, } from '@openreader/compute-core/contracts'; import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; @@ -75,6 +76,7 @@ interface StoredJobState { result?: Result; error?: WorkerJobErrorShape; timing?: WorkerJobTiming; + progress?: PdfLayoutProgress; } interface OpIndexEntry { @@ -772,6 +774,7 @@ async function main(): Promise { const runLayout = async ( payload: PdfLayoutJobRequest, queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, ): Promise => { const parsed = layoutSchema.parse(payload); @@ -779,17 +782,38 @@ async function main(): Promise { const pdfBytes = await readObjectByKey(parsed.documentObjectKey); const s3FetchMs = Date.now() - s3FetchStartedAt; + let lastTotalPages = 0; + let lastPagesParsed = 0; const computeStartedAt = Date.now(); const result = await withTimeout( runPdfLayoutFromPdfBuffer({ documentId: parsed.documentId, pdfBytes, + onPageParsed: async ({ pageNumber, totalPages }) => { + lastTotalPages = totalPages; + lastPagesParsed = pageNumber; + if (!hooks?.onProgress) return; + await hooks.onProgress({ + totalPages, + pagesParsed: pageNumber, + currentPage: pageNumber, + phase: 'infer', + }); + }, }), pdfTimeoutMs, 'pdf layout job', ); const computeMs = Date.now() - computeStartedAt; + if (hooks?.onProgress && lastTotalPages > 0) { + await hooks.onProgress({ + totalPages: lastTotalPages, + pagesParsed: lastPagesParsed, + currentPage: lastPagesParsed || undefined, + phase: 'merge', + }); + } const parsedObjectKey = await putParsedObject(parsed.documentId, parsed.namespace, result.parsed); return { parsedObjectKey, @@ -804,11 +828,16 @@ async function main(): Promise { async function processMessage(input: { msg: JsMsg; codec: JsonCodec>; - run: (payload: TPayload, queueWaitMs: number) => Promise; + run: ( + payload: TPayload, + queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, + ) => Promise; workerLabel: string; }): Promise { let decoded: QueuedJob | null = null; let heartbeat: NodeJS.Timeout | null = null; + let latestProgress: PdfLayoutProgress | undefined; try { decoded = input.codec.decode(input.msg.data); const startedAt = Date.now(); @@ -824,6 +853,7 @@ async function main(): Promise { startedAt, updatedAt: startedAt, ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }; await putOpState(runningState); @@ -837,11 +867,11 @@ async function main(): Promise { startedAt, updatedAt: startedAt, ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }); - heartbeat = setInterval(() => { - const now = Date.now(); - const heartbeatState: WorkerOperationState = { + const persistRunningState = async (updatedAt: number): Promise => { + const runningOpState: WorkerOperationState = { opId: decoded!.opId, opKey: decoded!.opKey, kind: decoded!.kind, @@ -849,18 +879,13 @@ async function main(): Promise { status: 'running', queuedAt: decoded!.queuedAt, startedAt, - updatedAt: now, + updatedAt, ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }; - void putOpState(heartbeatState).catch((stateError) => { - app.log.error({ - worker: input.workerLabel, - opId: decoded?.opId, - jobId: decoded?.jobId, - error: toErrorMessage(stateError), - }, 'failed to persist running heartbeat op state'); - }); - void putJobState({ + + await putOpState(runningOpState); + await putJobState({ jobId: decoded!.jobId, opId: decoded!.opId, opKey: decoded!.opKey, @@ -868,19 +893,30 @@ async function main(): Promise { status: 'running', timestamp: decoded!.queuedAt, startedAt, - updatedAt: now, + updatedAt, ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - }).catch((stateError) => { + ...(latestProgress ? { progress: latestProgress } : {}), + }); + }; + + heartbeat = setInterval(() => { + const now = Date.now(); + void persistRunningState(now).catch((stateError) => { app.log.error({ worker: input.workerLabel, opId: decoded?.opId, jobId: decoded?.jobId, error: toErrorMessage(stateError), - }, 'failed to persist running heartbeat job state'); + }, 'failed to persist running heartbeat state'); }); }, RUNNING_HEARTBEAT_MS); - const result = await input.run(decoded.payload, queueWaitMs ?? 0); + const result = await input.run(decoded.payload, queueWaitMs ?? 0, { + onProgress: async (progress) => { + latestProgress = progress; + await persistRunningState(Date.now()); + }, + }); const resultTiming = result && typeof result === 'object' && 'timing' in result ? (result as { timing?: WorkerJobTiming }).timing : undefined; @@ -897,6 +933,7 @@ async function main(): Promise { updatedAt: now, result: result as WhisperAlignJobResult | PdfLayoutJobResult, ...(resultTiming ? { timing: resultTiming } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }; await putOpState(succeededState); @@ -911,6 +948,7 @@ async function main(): Promise { updatedAt: now, result: result as WhisperAlignJobResult | PdfLayoutJobResult, ...(resultTiming ? { timing: resultTiming } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }); input.msg.ack(); @@ -941,6 +979,7 @@ async function main(): Promise { updatedAt: now, ...(status === 'failed' ? { error: { message } } : {}), ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }; await putOpState(opState).catch((stateError) => { @@ -962,6 +1001,7 @@ async function main(): Promise { updatedAt: now, ...(status === 'failed' ? { error: { message } } : {}), ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(latestProgress ? { progress: latestProgress } : {}), }).catch((stateError) => { app.log.error({ worker: input.workerLabel, @@ -1001,7 +1041,11 @@ async function main(): Promise { async function createWorkerLoop(input: { consumer: Consumer; codec: JsonCodec>; - run: (payload: TPayload, queueWaitMs: number) => Promise; + run: ( + payload: TPayload, + queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, + ) => Promise; workerLabel: string; }): Promise { while (!stopping) { diff --git a/drizzle/postgres/0007_pdf_parse_progress.sql b/drizzle/postgres/0007_pdf_parse_progress.sql new file mode 100644 index 0000000..9c2d188 --- /dev/null +++ b/drizzle/postgres/0007_pdf_parse_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE "documents" ADD COLUMN "parse_state" text;--> statement-breakpoint +ALTER TABLE "documents" DROP COLUMN "parse_status"; \ No newline at end of file diff --git a/drizzle/postgres/meta/0007_snapshot.json b/drizzle/postgres/meta/0007_snapshot.json new file mode 100644 index 0000000..ffc0c25 --- /dev/null +++ b/drizzle/postgres/meta/0007_snapshot.json @@ -0,0 +1,1831 @@ +{ + "id": "68baf959-cd1d-418b-a04e-11397aa05c39", + "prevId": "4d83c2e9-61f5-4a0a-a49d-9b68e7912375", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index ee17326..eccd0d6 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1779102950715, "tag": "0006_preview-defaults-cleanup", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1779378127846, + "tag": "0007_pdf_parse_progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0007_pdf_parse_progress.sql b/drizzle/sqlite/0007_pdf_parse_progress.sql new file mode 100644 index 0000000..b9a4631 --- /dev/null +++ b/drizzle/sqlite/0007_pdf_parse_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE `documents` ADD `parse_state` text;--> statement-breakpoint +ALTER TABLE `documents` DROP COLUMN `parse_status`; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0007_snapshot.json b/drizzle/sqlite/meta/0007_snapshot.json new file mode 100644 index 0000000..37cc0da --- /dev/null +++ b/drizzle/sqlite/meta/0007_snapshot.json @@ -0,0 +1,1693 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "031a62ae-47a7-4476-a577-7b14ff787eba", + "prevId": "b4c531bb-7ddb-40e6-a946-815a6521653e", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 32df1e6..8db8d88 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1779102950385, "tag": "0006_preview-defaults-cleanup", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779378125905, + "tag": "0007_pdf_parse_progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 9646da7..52b6f9c 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -42,6 +42,7 @@ export default function PDFViewerPage() { currDocPage, currDocPages, parseStatus, + parseProgress, documentSettings, updateDocumentSettings, parsedOverlayEnabled, @@ -240,30 +241,113 @@ export default function PDFViewerPage() { } const renderPdfStatusLoader = () => { + const totalPages = parseProgress?.totalPages ?? 0; + const pagesParsed = parseProgress?.pagesParsed ?? 0; + const progressPercent = totalPages > 0 + ? Math.max(0, Math.min(100, (pagesParsed / totalPages) * 100)) + : 0; + const hasMeasuredProgress = totalPages > 0; + const isMerging = parseProgress?.phase === 'merge'; + let statusText = 'Loading PDF...'; + let statusSubText = 'Initializing document renderer'; if (!isLoading) { if (parseState === 'pending') { - statusText = 'Preparing PDF layout...'; + statusText = parseProgress + ? `Page ${Math.max(0, parseProgress.pagesParsed)} / ${parseProgress.totalPages} parsed` + : 'Preparing PDF layout...'; + statusSubText = parseProgress?.phase === 'merge' + ? 'Finalizing stitched block structure' + : 'Queueing parser and preparing page extraction'; } else if (parseState === 'running') { - statusText = 'Parsing PDF layout blocks...'; + statusText = parseProgress + ? `Page ${Math.max(0, parseProgress.pagesParsed)} / ${parseProgress.totalPages} parsed` + : 'Parsing PDF layout blocks...'; + statusSubText = parseProgress?.phase === 'merge' + ? 'Merging cross-page sections' + : 'Inferring reading order and text regions'; } else if (parseState === 'failed') { statusText = 'PDF parsing failed. Retry to continue.'; + statusSubText = 'The parser could not build a usable layout map'; } } + const stageOneComplete = !isLoading; + const stageTwoActive = parseState === 'running' && !isMerging; + const stageTwoComplete = (parseState === 'running' && isMerging) || parseState === 'ready'; + const stageThreeActive = parseState === 'running' && isMerging; + const stageThreeComplete = parseState === 'ready'; + return ( -
- -

{statusText}

- {!isLoading && parseState === 'failed' ? ( - - ) : null} +
+
+
+
+
+
+

PDF Layout Parse

+

{statusText}

+

{statusSubText}

+
+
+ + + {parseState === 'failed' ? 'blocked' : (isMerging ? 'merge' : 'infer')} + +
+
+ +
+
+ Progress + + {hasMeasuredProgress ? `${Math.round(progressPercent)}%` : 'Starting'} + +
+
+
+
+
+ {hasMeasuredProgress ? `Page ${pagesParsed}/${totalPages}` : 'Awaiting first page'} + + {isMerging ? 'Cross-page merge' : 'Page inference'} +
+
+
+ +
+
+ + + Prepare + + + + Infer + + + + Merge + +
+
+ + {!isLoading && parseState === 'failed' ? ( +
+ +
+ ) : null} +
+
); }; diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 3476931..4c50459 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -24,6 +24,7 @@ import { getDocumentSettings, getParsedPdfDocument, putDocumentSettings, + subscribeParsedPdfDocumentEvents, } from '@/lib/client/api/documents'; import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf'; import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline'; @@ -44,7 +45,7 @@ import { type DocumentSettings, } from '@/types/document-settings'; import { mergeDocumentSettings } from '@/lib/shared/document-settings'; -import type { ParsedPdfDocument, ParsedPdfPage, PdfParseStatus } from '@/types/parsed-pdf'; +import type { ParsedPdfDocument, ParsedPdfPage, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { TTSSentenceAlignment, @@ -68,6 +69,7 @@ export interface PdfDocumentState { pdfDocument: PDFDocumentProxy | undefined; parsedDocument: ParsedPdfDocument | null; parseStatus: PdfParseStatus | null; + parseProgress: PdfParseProgress | null; documentSettings: DocumentSettings; updateDocumentSettings: (settings: DocumentSettings) => Promise; parsedOverlayEnabled: boolean; @@ -143,6 +145,7 @@ export function usePdfDocument(): PdfDocumentState { const [pdfDocument, setPdfDocument] = useState(); const [parsedDocument, setParsedDocument] = useState(null); const [parseStatus, setParseStatus] = useState(null); + const [parseProgress, setParseProgress] = useState(null); const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); @@ -164,6 +167,7 @@ export function usePdfDocument(): PdfDocumentState { const docLoadSeqRef = useRef(0); const docLoadAbortRef = useRef(null); const parsePollAbortRef = useRef(null); + const parseSseCloseRef = useRef<(() => void) | null>(null); const fetchParsedDocument = useCallback(async ( documentId: string, @@ -174,11 +178,11 @@ export function usePdfDocument(): PdfDocumentState { // document backfills parse output via the parsed endpoint polling path. const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending'; setParseStatus(effectiveInitialStatus); - - const maxAttempts = 25; + setParseProgress(null); const delayMs = 1200; const retryFailed = effectiveInitialStatus === 'failed'; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + let attempt = 0; + while (!signal.aborted) { if (signal.aborted) return; const result = await getParsedPdfDocument(documentId, { signal, @@ -187,13 +191,16 @@ export function usePdfDocument(): PdfDocumentState { if (result.status === 'ready') { setParsedDocument(result.parsed); setParseStatus('ready'); + setParseProgress(null); return; } setParseStatus(result.status); + setParseProgress(result.parseProgress ?? null); if (result.status === 'failed') { setParsedDocument(null); return; } + attempt += 1; await new Promise((resolve) => setTimeout(resolve, delayMs)); } }, []); @@ -211,13 +218,54 @@ export function usePdfDocument(): PdfDocumentState { const startParsedPolling = useCallback((documentId: string, initialStatus: PdfParseStatus | null) => { parsePollAbortRef.current?.abort(); + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; + setParseProgress(null); const controller = new AbortController(); parsePollAbortRef.current = controller; - void fetchParsedDocument(documentId, initialStatus, controller.signal).finally(() => { + + const closeSse = subscribeParsedPdfDocumentEvents(documentId, { + onSnapshot: (snapshot) => { + if (controller.signal.aborted) return; + setParseStatus(snapshot.parseStatus); + setParseProgress(snapshot.parseProgress); + if (snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed') { + if (snapshot.parseStatus === 'failed') { + setParsedDocument(null); + } else { + void fetchParsedDocument(documentId, 'ready', controller.signal); + } + closeSse(); + parseSseCloseRef.current = null; + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + return; + } + }, + onError: () => { + // Fall back to parsed polling if browser SSE disconnects. + if (controller.signal.aborted) return; + closeSse(); + parseSseCloseRef.current = null; + void fetchParsedDocument(documentId, initialStatus, controller.signal).finally(() => { + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + }); + }, + }); + parseSseCloseRef.current = closeSse; + + controller.signal.addEventListener('abort', () => { + closeSse(); + if (parseSseCloseRef.current === closeSse) { + parseSseCloseRef.current = null; + } if (parsePollAbortRef.current === controller) { parsePollAbortRef.current = null; } - }); + }, { once: true }); }, [fetchParsedDocument]); useEffect(() => { @@ -392,6 +440,8 @@ export function usePdfDocument(): PdfDocumentState { loadSeqRef.current += 1; parsePollAbortRef.current?.abort(); parsePollAbortRef.current = null; + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); @@ -401,6 +451,7 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocData(undefined); setParsedDocument(null); setParseStatus(null); + setParseProgress(null); setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); @@ -465,6 +516,7 @@ export function usePdfDocument(): PdfDocumentState { await forceReparsePdfDocument(currDocId); setParsedDocument(null); setParseStatus('pending'); + setParseProgress(null); startParsedPolling(currDocId, 'pending'); } catch (error) { console.error('Failed to force PDF reparse:', error); @@ -485,6 +537,8 @@ export function usePdfDocument(): PdfDocumentState { docLoadAbortRef.current = null; parsePollAbortRef.current?.abort(); parsePollAbortRef.current = null; + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; setCurrDocId(undefined); setCurrDocName(undefined); setCurrDocData(undefined); @@ -493,6 +547,7 @@ export function usePdfDocument(): PdfDocumentState { setPdfDocument(undefined); setParsedDocument(null); setParseStatus(null); + setParseProgress(null); setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); pageTextCacheRef.current.clear(); stop(); @@ -595,6 +650,7 @@ export function usePdfDocument(): PdfDocumentState { currDocText, parsedDocument, parseStatus, + parseProgress, documentSettings, updateDocumentSettings, parsedOverlayEnabled, @@ -621,6 +677,7 @@ export function usePdfDocument(): PdfDocumentState { currDocText, parsedDocument, parseStatus, + parseProgress, documentSettings, updateDocumentSettings, parsedOverlayEnabled, diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts new file mode 100644 index 0000000..76c4955 --- /dev/null +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; + +export const dynamic = 'force-dynamic'; + +const SSE_POLL_INTERVAL_MS = 1200; + +type ParseRow = { + id: string; + userId: string; + parseState: string | null; +}; + +type ParsedSnapshot = { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; +}; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function toSnapshot(row: ParseRow): ParsedSnapshot { + const state = parseDocumentParseState(row.parseState); + const parseStatus = normalizeParseStatus(state.status); + return { + parseStatus, + parseProgress: state.progress ?? null, + }; +} + +async function loadPreferredRow(input: { + documentId: string; + storageUserId: string; + allowedUserIds: string[]; +}): Promise { + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseState: documents.parseState, + }) + .from(documents) + .where(and(eq(documents.id, input.documentId), inArray(documents.userId, input.allowedUserIds)))) as ParseRow[]; + + return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const row = await loadPreferredRow({ + documentId: id, + storageUserId, + allowedUserIds, + }); + + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const initial = toSnapshot(row); + if (initial.parseStatus === 'pending') { + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + + const writeSnapshot = (snapshot: ParsedSnapshot): void => { + controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`)); + }; + + const run = async () => { + let current = initial; + writeSnapshot(current); + let signature = JSON.stringify(current); + + while (!closed) { + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') break; + await sleep(SSE_POLL_INTERVAL_MS); + if (closed) break; + + const nextRow = await loadPreferredRow({ + documentId: id, + storageUserId, + allowedUserIds, + }); + if (!nextRow) break; + + const next = toSnapshot(nextRow); + if (next.parseStatus === 'pending') { + enqueueParsePdfJob({ + documentId: id, + userId: nextRow.userId, + namespace: testNamespace, + }); + } + + const nextSignature = JSON.stringify(next); + if (nextSignature !== signature) { + current = next; + signature = nextSignature; + writeSnapshot(current); + } + } + }; + + void run() + .catch((error) => { + if (!closed) { + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + } + }) + .finally(() => { + if (!closed) { + closed = true; + try { + controller.close(); + } catch { + // no-op + } + } + }); + + req.signal.addEventListener('abort', () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + // no-op + } + }, { once: true }); + }, + cancel() { + return; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); + } catch (error) { + console.error('Error streaming parsed PDF progress:', error); + return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 23580ae..4fe7ea5 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -10,6 +10,11 @@ import { isValidDocumentId, } from '@/lib/server/documents/blobstore'; import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; +import { + normalizeParseStatus, + parseDocumentParseState, + stringifyDocumentParseState, +} from '@/lib/server/documents/parse-state'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { ParsedPdfDocument } from '@/types/parsed-pdf'; @@ -28,15 +33,6 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); } -function normalizeParseStatus( - status: string | null, -): 'pending' | 'running' | 'ready' | 'failed' { - if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { - return status; - } - return 'pending'; -} - export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -60,14 +56,14 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string .select({ id: documents.id, userId: documents.userId, - parseStatus: documents.parseStatus, + parseState: documents.parseState, parsedJsonKey: documents.parsedJsonKey, }) .from(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; - parseStatus: string | null; + parseState: string | null; parsedJsonKey: string | null; }>; @@ -76,31 +72,26 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const effectiveStatus = normalizeParseStatus(row.parseStatus); - if (row.parseStatus !== effectiveStatus) { - await db - .update(documents) - .set({ parseStatus: 'pending' }) - .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); - enqueueParsePdfJob({ - documentId: id, - userId: row.userId, - namespace: testNamespace, - }); - return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); - } + const state = parseDocumentParseState(row.parseState); + const effectiveStatus = normalizeParseStatus(state.status); if (effectiveStatus === 'failed' && retryFailed) { await db .update(documents) - .set({ parseStatus: 'pending' }) + .set({ + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + }), + }) .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); enqueueParsePdfJob({ documentId: id, userId: row.userId, namespace: testNamespace, }); - return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + return NextResponse.json({ parseStatus: 'pending', parseProgress: null }, { status: 202 }); } if (effectiveStatus !== 'ready') { @@ -111,7 +102,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string namespace: testNamespace, }); } - return NextResponse.json({ parseStatus: effectiveStatus }, { status: 202 }); + return NextResponse.json({ + parseStatus: effectiveStatus, + parseProgress: state.progress ?? null, + }, { status: 202 }); } try { @@ -174,14 +168,14 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string .select({ id: documents.id, userId: documents.userId, - parseStatus: documents.parseStatus, + parseState: documents.parseState, parsedJsonKey: documents.parsedJsonKey, }) .from(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ id: string; userId: string; - parseStatus: string | null; + parseState: string | null; parsedJsonKey: string | null; }>; @@ -190,12 +184,19 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const effectiveStatus = normalizeParseStatus(row.parseStatus); + const state = parseDocumentParseState(row.parseState); + const effectiveStatus = normalizeParseStatus(state.status); if (effectiveStatus !== 'running') { await db .update(documents) - .set({ parseStatus: 'pending' }) + .set({ + parseState: stringifyDocumentParseState({ + status: 'pending', + progress: null, + updatedAt: Date.now(), + }), + }) .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); } @@ -206,7 +207,10 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }); return NextResponse.json( - { parseStatus: effectiveStatus === 'running' ? 'running' : 'pending' }, + { + parseStatus: effectiveStatus === 'running' ? 'running' : 'pending', + parseProgress: effectiveStatus === 'running' ? (state.progress ?? null) : null, + }, { status: 202 }, ); } catch (error) { diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 0d6a7b1..05c6aeb 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -11,6 +11,11 @@ import { } from '@/lib/server/documents/previews'; import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { + normalizeParseStatus, + parseDocumentParseState, + stringifyDocumentParseState, +} from '@/lib/server/documents/parse-state'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { BaseDocument, DocumentType } from '@/types/documents'; @@ -25,16 +30,6 @@ type RegisterDocument = { lastModified: number; }; -function normalizeParseStatus( - status: string | null, -): 'pending' | 'running' | 'ready' | 'failed' | null { - if (status === null) return null; - if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { - return status; - } - return 'pending'; -} - function s3NotConfiguredResponse(): NextResponse { return NextResponse.json( { error: 'Documents storage is not configured. Set S3_* environment variables.' }, @@ -140,7 +135,9 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, - parseStatus: doc.type === 'pdf' ? 'pending' : null, + parseState: doc.type === 'pdf' + ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + : null, parsedJsonKey: null, }) .onConflictDoUpdate({ @@ -151,7 +148,9 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, - parseStatus: doc.type === 'pdf' ? 'pending' : null, + parseState: doc.type === 'pdf' + ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + : null, parsedJsonKey: null, }, }); @@ -229,7 +228,7 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; - parseStatus: string | null; + parseState: string | null; parsedJsonKey: string | null; }>; @@ -255,7 +254,7 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, - parseStatus: normalizeParseStatus(doc.parseStatus), + parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null, parsedJsonKey: doc.parsedJsonKey, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 6de592f..13f3061 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -12,7 +12,7 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), - parseStatus: text('parse_status'), + parseState: text('parse_state'), parsedJsonKey: text('parsed_json_key'), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 7bf0ade..2781e7d 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -12,7 +12,7 @@ export const documents = sqliteTable('documents', { size: integer('size').notNull(), lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), - parseStatus: text('parse_status'), + parseState: text('parse_state'), parsedJsonKey: text('parsed_json_key'), createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 882ad4c..5e73292 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -1,6 +1,6 @@ import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; -import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { ParsedPdfDocument, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { DocumentSettings } from '@/types/document-settings'; export type UploadSource = { @@ -81,7 +81,10 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort export async function getParsedPdfDocument( id: string, options?: { signal?: AbortSignal; retryFailed?: boolean }, -): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' }> { +): Promise< + | { status: 'ready'; parsed: ParsedPdfDocument } + | { status: 'pending' | 'running' | 'failed'; parseProgress?: PdfParseProgress | null } +> { const query = options?.retryFailed ? '?retry=1' : ''; const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { signal: options?.signal, @@ -89,12 +92,12 @@ export async function getParsedPdfDocument( }); if (res.status === 202) { - const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; + const data = (await res.json().catch(() => null)) as { parseStatus?: string; parseProgress?: PdfParseProgress | null } | null; const parseStatus = data?.parseStatus; if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed') { - return { status: parseStatus }; + return { status: parseStatus, parseProgress: data?.parseProgress ?? null }; } - return { status: 'pending' }; + return { status: 'pending', parseProgress: data?.parseProgress ?? null }; } if (!res.ok) { @@ -106,6 +109,31 @@ export async function getParsedPdfDocument( return { status: 'ready', parsed }; } +export function subscribeParsedPdfDocumentEvents( + id: string, + handlers: { + onSnapshot: (snapshot: { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null }) => void; + onError?: (error: Event) => void; + }, +): () => void { + const source = new EventSource(`/api/documents/${encodeURIComponent(id)}/parsed/events`); + source.addEventListener('snapshot', (event) => { + if (!(event instanceof MessageEvent)) return; + try { + const payload = JSON.parse(event.data) as { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null }; + handlers.onSnapshot(payload); + } catch { + // Ignore malformed payloads to avoid breaking active streams. + } + }); + source.addEventListener('error', (event) => { + handlers.onError?.(event); + }); + return () => { + source.close(); + }; +} + export async function forceReparsePdfDocument( id: string, options?: { signal?: AbortSignal }, diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index 0315682..f4e0922 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -35,6 +35,17 @@ export class LocalComputeBackend implements ComputeBackend { if (!pdfBytes) { throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); } - return { parsed: (await runPdfLayoutFromPdfBuffer({ documentId: input.documentId, pdfBytes })).parsed }; + return { + parsed: (await runPdfLayoutFromPdfBuffer({ + documentId: input.documentId, + pdfBytes, + onPageParsed: (page) => input.onProgress?.({ + totalPages: page.totalPages, + pagesParsed: page.pageNumber, + currentPage: page.pageNumber, + phase: 'infer', + }), + })).parsed, + }; } } diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index aacbb10..9b86c20 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -1,4 +1,9 @@ -import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core/contracts'; +import type { + TTSAudioBuffer, + TTSSentenceAlignment, + ParsedPdfDocument, + PdfLayoutProgress, +} from '@openreader/compute-core/contracts'; export type ComputeMode = 'local' | 'worker'; @@ -19,6 +24,7 @@ export interface PdfLayoutInput { namespace?: string | null; documentObjectKey?: string; pdfBytes?: ArrayBuffer; + onProgress?: (progress: PdfLayoutProgress) => void | Promise; } export type PdfLayoutResult = diff --git a/src/lib/server/compute/worker-contract.ts b/src/lib/server/compute/worker-contract.ts index 19e6bc5..950e62a 100644 --- a/src/lib/server/compute/worker-contract.ts +++ b/src/lib/server/compute/worker-contract.ts @@ -1,6 +1,8 @@ export { type PdfLayoutJobRequest, type PdfLayoutJobResult, + type PdfLayoutProgress, + type PdfLayoutProgressPhase, type PdfLayoutOperationRequest, type WhisperAlignJobRequest, type WhisperAlignJobResult, diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 55c9197..733c133 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -375,6 +375,11 @@ export class WorkerComputeBackend implements ComputeBackend { opKeyHash, documentId: input.documentId, attempt, + onSnapshot: (snapshot) => { + if (snapshot.progress) { + void input.onProgress?.(snapshot.progress); + } + }, }); if (final.status !== 'succeeded' || !final.result) { @@ -482,7 +487,9 @@ export class WorkerComputeBackend implements ComputeBackend { private async waitForOperation( opId: string, - context: Record = {}, + context: Record & { + onSnapshot?: (snapshot: WorkerOperationState) => void + } = {}, ): Promise> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.waitTimeoutMs); @@ -577,6 +584,7 @@ export class WorkerComputeBackend implements ComputeBackend { eventCount += 1; latest = snapshot; + context.onSnapshot?.(snapshot); if (snapshot.status !== lastStatus) { lastStatus = snapshot.status; logWorker('info', 'sse.wait.status', { diff --git a/src/lib/server/documents/parse-state.ts b/src/lib/server/documents/parse-state.ts new file mode 100644 index 0000000..133a6bf --- /dev/null +++ b/src/lib/server/documents/parse-state.ts @@ -0,0 +1,72 @@ +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; + +export interface DocumentParseState { + status: PdfParseStatus; + progress?: PdfParseProgress | null; + updatedAt?: number; + error?: string | null; + opId?: string; + jobId?: string; +} + +export function normalizeParseStatus(status: string | null | undefined): PdfParseStatus { + if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { + return status; + } + return 'pending'; +} + +function normalizeProgress(progress: unknown): PdfParseProgress | null { + if (!progress || typeof progress !== 'object') return null; + const rec = progress as Record; + const totalPages = Number(rec.totalPages ?? 0); + if (!Number.isFinite(totalPages) || totalPages <= 0) return null; + const pagesParsedRaw = Number(rec.pagesParsed ?? 0); + const pagesParsed = Math.max(0, Math.min(totalPages, Number.isFinite(pagesParsedRaw) ? pagesParsedRaw : 0)); + const phase = rec.phase === 'merge' ? 'merge' : 'infer'; + const currentPageRaw = Number(rec.currentPage ?? pagesParsed); + const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw > 0 + ? Math.max(1, Math.min(totalPages, currentPageRaw)) + : undefined; + return { + totalPages, + pagesParsed, + currentPage, + phase, + }; +} + +export function parseDocumentParseState(value: string | null): DocumentParseState { + if (!value) return { status: 'pending', progress: null }; + try { + const parsed = JSON.parse(value) as Record; + const status = normalizeParseStatus(typeof parsed.status === 'string' ? parsed.status : null); + const progress = normalizeProgress(parsed.progress); + const updatedAtRaw = Number(parsed.updatedAt ?? 0); + const updatedAt = Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? updatedAtRaw : undefined; + 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; + return { + status, + progress, + ...(typeof updatedAt === 'number' ? { updatedAt } : {}), + ...(error ? { error } : {}), + ...(opId ? { opId } : {}), + ...(jobId ? { jobId } : {}), + }; + } catch { + return { status: 'pending', progress: null }; + } +} + +export function stringifyDocumentParseState(state: DocumentParseState): string { + return JSON.stringify({ + status: normalizeParseStatus(state.status), + progress: state.progress ?? null, + updatedAt: typeof state.updatedAt === 'number' ? state.updatedAt : Date.now(), + ...(state.error ? { error: state.error } : {}), + ...(state.opId ? { opId: state.opId } : {}), + ...(state.jobId ? { jobId: state.jobId } : {}), + }); +} diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index 593d06d..bd4e3d3 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -2,8 +2,10 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state'; import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; +import type { PdfLayoutProgress } from '@openreader/compute-core/contracts'; interface ParsePdfJobInput { documentId: string; @@ -23,16 +25,41 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { running.add(key); try { + const now = Date.now(); await db .update(documents) - .set({ parseStatus: 'running' }) + .set({ + parseState: stringifyDocumentParseState({ + status: 'running', + progress: null, + updatedAt: now, + }), + }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); const compute = await getCompute(); + const writeProgress = async (progress: PdfLayoutProgress): Promise => { + await db + .update(documents) + .set({ + parseState: stringifyDocumentParseState({ + status: 'running', + progress: { + totalPages: progress.totalPages, + pagesParsed: progress.pagesParsed, + currentPage: progress.currentPage, + phase: progress.phase, + }, + updatedAt: Date.now(), + }), + }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + }; const layout = await compute.parsePdfLayout({ documentId: input.documentId, namespace: input.namespace, documentObjectKey: documentKey(input.documentId, input.namespace), + onProgress: writeProgress, }); let parsedJsonKey = layout.parsedObjectKey ?? null; @@ -56,7 +83,14 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { await db .update(documents) - .set({ parseStatus: 'ready', parsedJsonKey }) + .set({ + parseState: stringifyDocumentParseState({ + status: 'ready', + progress: null, + updatedAt: Date.now(), + }), + parsedJsonKey, + }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -66,7 +100,14 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { try { await db .update(documents) - .set({ parseStatus }) + .set({ + parseState: stringifyDocumentParseState({ + status: 'failed', + progress: null, + updatedAt: Date.now(), + error: message, + }), + }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); } catch (statusError) { console.error('[parsePdfJob] failed to write parse status', { diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts index 2ee67af..a7d5650 100644 --- a/src/types/parsed-pdf.ts +++ b/src/types/parsed-pdf.ts @@ -77,3 +77,11 @@ export interface ParsedPdfDocument { } export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed'; +export type PdfParsePhase = 'infer' | 'merge'; + +export interface PdfParseProgress { + totalPages: number; + pagesParsed: number; + currentPage?: number; + phase: PdfParsePhase; +} diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index c349897..ca58e28 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -20,7 +20,7 @@ test.describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, - parse_status TEXT, + parse_state TEXT, parsed_json_key TEXT, created_at INTEGER, PRIMARY KEY (id, user_id) From 3aad8a51e4c03095db40ddd05c443544383d1f18 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 10:30:36 -0600 Subject: [PATCH 038/137] Add local compute concurrency limiter with tests --- src/lib/server/compute/concurrency-limiter.ts | 40 +++++++++++ src/lib/server/compute/local.ts | 71 ++++++++++--------- .../unit/compute-concurrency-limiter.spec.ts | 65 +++++++++++++++++ 3 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 src/lib/server/compute/concurrency-limiter.ts create mode 100644 tests/unit/compute-concurrency-limiter.spec.ts diff --git a/src/lib/server/compute/concurrency-limiter.ts b/src/lib/server/compute/concurrency-limiter.ts new file mode 100644 index 0000000..08102db --- /dev/null +++ b/src/lib/server/compute/concurrency-limiter.ts @@ -0,0 +1,40 @@ +export class ConcurrencyLimiter { + private readonly maxInFlight: number; + private inFlight = 0; + private readonly queue: Array<() => void> = []; + + constructor(limit: number) { + this.maxInFlight = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : 1; + } + + async run(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.inFlight < this.maxInFlight) { + this.inFlight += 1; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.queue.push(() => { + this.inFlight += 1; + resolve(); + }); + }); + } + + private release(): void { + this.inFlight = Math.max(0, this.inFlight - 1); + const next = this.queue.shift(); + if (next) next(); + } +} + +export const LOCAL_PDF_LIMITER = new ConcurrencyLimiter(2); +export const LOCAL_WHISPER_LIMITER = new ConcurrencyLimiter(1); diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts index f4e0922..4d3ee0c 100644 --- a/src/lib/server/compute/local.ts +++ b/src/lib/server/compute/local.ts @@ -1,4 +1,5 @@ import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { LOCAL_PDF_LIMITER, LOCAL_WHISPER_LIMITER } from '@/lib/server/compute/concurrency-limiter'; import { getDocumentBlob } from '@/lib/server/documents/blobstore'; import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; import { @@ -10,42 +11,46 @@ export class LocalComputeBackend implements ComputeBackend { readonly mode = 'local' as const; async alignWords(input: WhisperAlignInput): Promise { - let audioBuffer = input.audioBuffer ?? null; - if (!audioBuffer && input.audioObjectKey) { - const bytes = new Uint8Array(await getTtsSegmentAudioObject(input.audioObjectKey)); - audioBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - } - if (!audioBuffer) { - throw new Error('Local compute alignment requires audioBuffer or audioObjectKey'); - } - return runWhisperAlignmentFromAudioBuffer({ - audioBuffer, - text: input.text, - cacheKey: input.cacheKey, - lang: input.lang, + return LOCAL_WHISPER_LIMITER.run(async () => { + let audioBuffer = input.audioBuffer ?? null; + if (!audioBuffer && input.audioObjectKey) { + const bytes = new Uint8Array(await getTtsSegmentAudioObject(input.audioObjectKey)); + audioBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + if (!audioBuffer) { + throw new Error('Local compute alignment requires audioBuffer or audioObjectKey'); + } + return runWhisperAlignmentFromAudioBuffer({ + audioBuffer, + text: input.text, + cacheKey: input.cacheKey, + lang: input.lang, + }); }); } async parsePdfLayout(input: PdfLayoutInput) { - let pdfBytes = input.pdfBytes ?? null; - if (!pdfBytes && input.documentId && typeof input.namespace !== 'undefined') { - const bytes = new Uint8Array(await getDocumentBlob(input.documentId, input.namespace)); - pdfBytes = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - } - if (!pdfBytes) { - throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); - } - return { - parsed: (await runPdfLayoutFromPdfBuffer({ - documentId: input.documentId, - pdfBytes, - onPageParsed: (page) => input.onProgress?.({ - totalPages: page.totalPages, - pagesParsed: page.pageNumber, - currentPage: page.pageNumber, - phase: 'infer', - }), - })).parsed, - }; + return LOCAL_PDF_LIMITER.run(async () => { + let pdfBytes = input.pdfBytes ?? null; + if (!pdfBytes && input.documentId && typeof input.namespace !== 'undefined') { + const bytes = new Uint8Array(await getDocumentBlob(input.documentId, input.namespace)); + pdfBytes = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + if (!pdfBytes) { + throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); + } + return { + parsed: (await runPdfLayoutFromPdfBuffer({ + documentId: input.documentId, + pdfBytes, + onPageParsed: (page) => input.onProgress?.({ + totalPages: page.totalPages, + pagesParsed: page.pageNumber, + currentPage: page.pageNumber, + phase: 'infer', + }), + })).parsed, + }; + }); } } diff --git a/tests/unit/compute-concurrency-limiter.spec.ts b/tests/unit/compute-concurrency-limiter.spec.ts new file mode 100644 index 0000000..a37349b --- /dev/null +++ b/tests/unit/compute-concurrency-limiter.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { ConcurrencyLimiter } from '../../src/lib/server/compute/concurrency-limiter'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +test.describe('compute-concurrency-limiter', () => { + test('caps active jobs at configured limit', async () => { + const limiter = new ConcurrencyLimiter(2); + let inFlight = 0; + let maxInFlightSeen = 0; + + const jobs = Array.from({ length: 6 }, (_, i) => limiter.run(async () => { + inFlight += 1; + maxInFlightSeen = Math.max(maxInFlightSeen, inFlight); + await sleep(20 + (i % 2) * 5); + inFlight -= 1; + return i; + })); + + const result = await Promise.all(jobs); + expect(result).toEqual([0, 1, 2, 3, 4, 5]); + expect(maxInFlightSeen).toBe(2); + }); + + test('queues in FIFO order when saturated', async () => { + const limiter = new ConcurrencyLimiter(1); + const starts: number[] = []; + + const one = limiter.run(async () => { + starts.push(1); + await sleep(25); + }); + const two = limiter.run(async () => { + starts.push(2); + await sleep(5); + }); + const three = limiter.run(async () => { + starts.push(3); + await sleep(1); + }); + + await Promise.all([one, two, three]); + expect(starts).toEqual([1, 2, 3]); + }); + + test('releases slot after failure', async () => { + const limiter = new ConcurrencyLimiter(1); + let startedSecond = false; + + const first = limiter.run(async () => { + await sleep(10); + throw new Error('boom'); + }); + const second = limiter.run(async () => { + startedSecond = true; + return 'ok'; + }); + + await expect(first).rejects.toThrow('boom'); + await expect(second).resolves.toBe('ok'); + expect(startedSecond).toBeTruthy(); + }); +}); From 10748c7fcdcb02d026ecd96e938adffb89c81394 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 10:32:18 -0600 Subject: [PATCH 039/137] Add force-token cache bust for worker PDF op keys --- src/app/api/documents/[id]/parsed/route.ts | 2 ++ src/lib/server/compute/types.ts | 1 + src/lib/server/compute/worker.ts | 3 ++- src/lib/server/jobs/parsePdfJob.ts | 2 ++ tests/unit/compute-worker-op-key.spec.ts | 29 ++++++++++++++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/unit/compute-worker-op-key.spec.ts diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 4fe7ea5..4c38631 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; @@ -204,6 +205,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string documentId: id, userId: row.userId, namespace: testNamespace, + forceToken: randomUUID(), }); return NextResponse.json( diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index 9b86c20..524857b 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -24,6 +24,7 @@ export interface PdfLayoutInput { namespace?: string | null; documentObjectKey?: string; pdfBytes?: ArrayBuffer; + forceToken?: string; onProgress?: (progress: PdfLayoutProgress) => void | Promise; } diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 733c133..4ec9c79 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -166,13 +166,14 @@ function buildWhisperOpKey(input: WhisperAlignInput): string { ].join('|'); } -function buildPdfOpKey(input: PdfLayoutInput): string { +export function buildPdfOpKey(input: PdfLayoutInput): string { return [ 'pdf_layout', 'v1', input.documentId, input.namespace ?? '', input.documentObjectKey ?? '', + input.forceToken?.trim() || '', ].join('|'); } diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts index bd4e3d3..87ac52c 100644 --- a/src/lib/server/jobs/parsePdfJob.ts +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -11,6 +11,7 @@ interface ParsePdfJobInput { documentId: string; userId: string; namespace: string | null; + forceToken?: string; } const running = new Set(); @@ -59,6 +60,7 @@ export async function parsePdfJob(input: ParsePdfJobInput): Promise { documentId: input.documentId, namespace: input.namespace, documentObjectKey: documentKey(input.documentId, input.namespace), + forceToken: input.forceToken, onProgress: writeProgress, }); diff --git a/tests/unit/compute-worker-op-key.spec.ts b/tests/unit/compute-worker-op-key.spec.ts new file mode 100644 index 0000000..2101af6 --- /dev/null +++ b/tests/unit/compute-worker-op-key.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { buildPdfOpKey } from '../../src/lib/server/compute/worker'; + +test.describe('compute worker pdf opKey', () => { + test('keeps stable key when no force token is provided', () => { + const base = { + documentId: 'doc-123', + namespace: 'ns-1', + documentObjectKey: 'docs/ns-1/doc-123', + }; + expect(buildPdfOpKey(base)).toBe('pdf_layout|v1|doc-123|ns-1|docs/ns-1/doc-123|'); + expect(buildPdfOpKey(base)).toBe(buildPdfOpKey(base)); + }); + + test('cache-busts key when force token is provided', () => { + const base = { + documentId: 'doc-123', + namespace: 'ns-1', + documentObjectKey: 'docs/ns-1/doc-123', + }; + const opKeyA = buildPdfOpKey({ ...base, forceToken: 'force-a' }); + const opKeyB = buildPdfOpKey({ ...base, forceToken: 'force-b' }); + const normal = buildPdfOpKey(base); + + expect(opKeyA).not.toBe(opKeyB); + expect(opKeyA).not.toBe(normal); + expect(opKeyB).not.toBe(normal); + }); +}); From dd494d50aaa9049535630100ed9e2d79f4709480 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 10:34:29 -0600 Subject: [PATCH 040/137] Add force-reparse confirmation flow in PDF UI --- src/app/(app)/pdf/[id]/page.tsx | 32 +++++++++++++++++-- src/components/documents/DocumentSettings.tsx | 5 +-- src/lib/client/pdf/force-reparse.ts | 9 ++++++ tests/unit/pdf-force-reparse.spec.ts | 24 ++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/lib/client/pdf/force-reparse.ts create mode 100644 tests/unit/pdf-force-reparse.spec.ts diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 52b6f9c..32abacf 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -10,6 +10,7 @@ import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { Header } from '@/components/Header'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; @@ -19,6 +20,12 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { LoadingSpinner } from '@/components/Spinner'; +import { + FORCE_REPARSE_CONFIRM_MESSAGE, + FORCE_REPARSE_CONFIRM_TEXT, + FORCE_REPARSE_CONFIRM_TITLE, + isForceReparseDisabled, +} from '@/lib/client/pdf/force-reparse'; import { usePdfDocument } from './usePdfDocument'; // Dynamic import for client-side rendering only @@ -58,6 +65,7 @@ export default function PDFViewerPage() { const [isPdfViewerReady, setIsPdfViewerReady] = useState(false); const [zoomLevel, setZoomLevel] = useState(100); const [activeSidebar, setActiveSidebar] = useState(null); + const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false); const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); @@ -66,6 +74,7 @@ export default function PDFViewerPage() { const [isNavigatingBack, setIsNavigatingBack] = useState(false); const parseState = parseStatus ?? 'pending'; const isParseReady = parseState === 'ready'; + const forceReparseDisabled = isForceReparseDisabled(parseStatus); useEffect(() => { setIsLoading(true); @@ -198,6 +207,16 @@ export default function PDFViewerPage() { }, delayMs); }, [isNavigatingBack, stop, activeSidebar, router]); + const requestForceReparse = useCallback(() => { + if (forceReparseDisabled) return; + setShowForceReparseConfirm(true); + }, [forceReparseDisabled]); + + const confirmForceReparse = useCallback(() => { + setShowForceReparseConfirm(false); + void forceReparseParsedPdf(); + }, [forceReparseParsedPdf]); + const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, signal: AbortSignal, @@ -339,7 +358,7 @@ export default function PDFViewerPage() {
diff --git a/src/lib/client/pdf/force-reparse.ts b/src/lib/client/pdf/force-reparse.ts new file mode 100644 index 0000000..425976d --- /dev/null +++ b/src/lib/client/pdf/force-reparse.ts @@ -0,0 +1,9 @@ +import type { PdfParseStatus } from '@/types/parsed-pdf'; + +export const FORCE_REPARSE_CONFIRM_TITLE = 'Reparse PDF Layout?'; +export const FORCE_REPARSE_CONFIRM_MESSAGE = 'This reruns page layout parsing from scratch and can take a while on large documents.'; +export const FORCE_REPARSE_CONFIRM_TEXT = 'Reparse Now'; + +export function isForceReparseDisabled(status: PdfParseStatus | null): boolean { + return status === 'pending' || status === 'running'; +} diff --git a/tests/unit/pdf-force-reparse.spec.ts b/tests/unit/pdf-force-reparse.spec.ts new file mode 100644 index 0000000..95c2ce4 --- /dev/null +++ b/tests/unit/pdf-force-reparse.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { + FORCE_REPARSE_CONFIRM_MESSAGE, + FORCE_REPARSE_CONFIRM_TEXT, + FORCE_REPARSE_CONFIRM_TITLE, + isForceReparseDisabled, +} from '../../src/lib/client/pdf/force-reparse'; + +test.describe('pdf force reparse controls', () => { + test('disables action while parse is pending or running', () => { + expect(isForceReparseDisabled('pending')).toBeTruthy(); + expect(isForceReparseDisabled('running')).toBeTruthy(); + expect(isForceReparseDisabled('ready')).toBeFalsy(); + expect(isForceReparseDisabled('failed')).toBeFalsy(); + expect(isForceReparseDisabled(null)).toBeFalsy(); + }); + + test('confirmation copy warns about expensive rerun', () => { + expect(FORCE_REPARSE_CONFIRM_TITLE).toContain('Reparse'); + expect(FORCE_REPARSE_CONFIRM_TEXT).toContain('Reparse'); + expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('from scratch'); + expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('take a while'); + }); +}); From bde8d74a97cb2904dac16d3fb2d36951b43c9029 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 10:54:07 -0600 Subject: [PATCH 041/137] refactor(pdf): improve parse loader UX and PDF viewer readiness signaling Enhance PDF parse loader transitions by introducing a delayed expansion for detailed progress, reducing UI flicker during parse-to-render handoff. Refactor PDFViewer to ensure document readiness is signaled only once per load, preventing redundant callbacks and improving synchronization between parsing and rendering states. --- src/app/(app)/pdf/[id]/page.tsx | 162 ++++++++++++++++------------- src/components/views/PDFViewer.tsx | 15 ++- 2 files changed, 105 insertions(+), 72 deletions(-) diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 32abacf..1cbb1f1 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -37,6 +37,8 @@ const PDFViewer = dynamic( } ); +const PARSE_LOADER_EXPAND_DELAY_MS = 320; + export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); @@ -66,6 +68,7 @@ export default function PDFViewerPage() { const [zoomLevel, setZoomLevel] = useState(100); const [activeSidebar, setActiveSidebar] = useState(null); const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false); + const [showDetailedParseLoader, setShowDetailedParseLoader] = useState(false); const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); @@ -75,6 +78,8 @@ export default function PDFViewerPage() { const parseState = parseStatus ?? 'pending'; const isParseReady = parseState === 'ready'; const forceReparseDisabled = isForceReparseDisabled(parseStatus); + const shouldShowExpandedParseLoader = !isLoading + && (parseState === 'pending' || parseState === 'running' || parseState === 'failed'); useEffect(() => { setIsLoading(true); @@ -163,6 +168,22 @@ export default function PDFViewerPage() { stop(); }, [isLoading, isParseReady, stop]); + useEffect(() => { + if (!shouldShowExpandedParseLoader) { + // Keep the current loader variant stable during the final + // parse-ready -> first-frame handoff to avoid a visual flash. + if (!isLoading && isParseReady && !isPdfViewerReady) { + return; + } + setShowDetailedParseLoader(false); + return; + } + const timeout = window.setTimeout(() => { + setShowDetailedParseLoader(true); + }, PARSE_LOADER_EXPAND_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [shouldShowExpandedParseLoader, id, isLoading, isParseReady, isPdfViewerReady]); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -260,6 +281,13 @@ export default function PDFViewerPage() { } const renderPdfStatusLoader = () => { + const compactLabel = isLoading + ? 'Opening PDF...' + : (parseState === 'ready' ? 'Rendering pages...' : 'Checking parse state...'); + const compactSubLabel = isLoading + ? 'Loading document data' + : (parseState === 'ready' ? 'Preparing first frame' : 'Preparing layout parser'); + const totalPages = parseProgress?.totalPages ?? 0; const pagesParsed = parseProgress?.pagesParsed ?? 0; const progressPercent = totalPages > 0 @@ -272,16 +300,12 @@ export default function PDFViewerPage() { let statusSubText = 'Initializing document renderer'; if (!isLoading) { if (parseState === 'pending') { - statusText = parseProgress - ? `Page ${Math.max(0, parseProgress.pagesParsed)} / ${parseProgress.totalPages} parsed` - : 'Preparing PDF layout...'; + statusText = 'Preparing PDF layout...'; statusSubText = parseProgress?.phase === 'merge' ? 'Finalizing stitched block structure' : 'Queueing parser and preparing page extraction'; } else if (parseState === 'running') { - statusText = parseProgress - ? `Page ${Math.max(0, parseProgress.pagesParsed)} / ${parseProgress.totalPages} parsed` - : 'Parsing PDF layout blocks...'; + statusText = 'Parsing PDF layout blocks...'; statusSubText = parseProgress?.phase === 'merge' ? 'Merging cross-page sections' : 'Inferring reading order and text regions'; @@ -291,81 +315,77 @@ export default function PDFViewerPage() { } } - const stageOneComplete = !isLoading; - const stageTwoActive = parseState === 'running' && !isMerging; - const stageTwoComplete = (parseState === 'running' && isMerging) || parseState === 'ready'; - const stageThreeActive = parseState === 'running' && isMerging; - const stageThreeComplete = parseState === 'ready'; + const stageLabel = parseState === 'failed' + ? 'Stage: blocked' + : (parseState === 'pending' + ? 'Stage: prepare' + : (isMerging ? 'Stage: merge' : 'Stage: infer')); return (
-
-
-
+
+ {showDetailedParseLoader ? ( +
+
+
+
+
+ + PDF Layout Parse +
+

{statusText}

+
+ +
+
+

+ {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'} +

+

{stageLabel}

+
+
+
+
+

+ {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText} +

+
+ + {!isLoading && parseState === 'failed' ? ( +
+ +
+ ) : null} +
+
+ ) : ( +
+
-

PDF Layout Parse

-

{statusText}

-

{statusSubText}

+

{compactLabel}

+

{compactSubLabel}

-
+ - - {parseState === 'failed' ? 'blocked' : (isMerging ? 'merge' : 'infer')} - -
+
- -
-
- Progress - - {hasMeasuredProgress ? `${Math.round(progressPercent)}%` : 'Starting'} - -
-
-
-
-
- {hasMeasuredProgress ? `Page ${pagesParsed}/${totalPages}` : 'Awaiting first page'} - - {isMerging ? 'Cross-page merge' : 'Page inference'} -
+
+ + +
- -
-
- - - Prepare - - - - Infer - - - - Merge - -
-
- - {!isLoading && parseState === 'failed' ? ( -
- -
- ) : null} -
+ )}
); diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 947f9c7..6ac12de 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -39,6 +39,7 @@ interface PDFOnLinkClickArgs { export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerProps) { const containerRef = useRef(null); const [isPageRendering, setIsPageRendering] = useState(false); + const hasSignaledReadyRef = useRef(false); const scaleRef = useRef(1); const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); @@ -99,6 +100,16 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro } }, [layoutKey]); + const markViewerReady = useCallback(() => { + if (hasSignaledReadyRef.current) return; + hasSignaledReadyRef.current = true; + onDocumentReady?.(); + }, [onDocumentReady]); + + useEffect(() => { + hasSignaledReadyRef.current = false; + }, [currDocId, currDocData]); + const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); sentenceHighlightTimeoutsRef.current = []; @@ -500,7 +511,6 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); - onDocumentReady?.(); }} onItemClick={(args: PDFOnLinkClickArgs) => { if (args?.pageNumber) { @@ -531,6 +541,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro onRenderSuccess={() => { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); + markViewerReady(); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); @@ -556,6 +567,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro onRenderSuccess={() => { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); + markViewerReady(); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); @@ -577,6 +589,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro onRenderSuccess={() => { lastRenderedLayoutKeyRef.current = layoutKey; setIsPageRendering(false); + markViewerReady(); }} onLoadSuccess={(page) => { setPageWidth(page.originalWidth); From 08df437e61de7810bf8ed05feaa93a165d6f0dfc Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 11:18:41 -0600 Subject: [PATCH 042/137] chore: add .npmrc for npm configuration --- .npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted From fe9104e8fce431afaacb90b4f60be5e63fc09911 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 21 May 2026 11:59:12 -0600 Subject: [PATCH 043/137] docs(pdf,spinner,config): update parse state UI, spinner theming, and worker env docs Revise PDF viewer to introduce an explicit 'unknown' parse state and improve loader and status messaging for better user clarity. Refactor Spinner to use 'currentColor' for border styling and simplify conditional rendering based on className. Expand compute worker .env.example with explicit concurrency, timeout, and job attempt variables for enhanced configuration transparency. --- compute/worker/.env.example | 7 ++++++- src/app/(app)/pdf/[id]/page.tsx | 30 +++++++++++++++++++----------- src/components/Spinner.tsx | 12 ++++++++++-- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/compute/worker/.env.example b/compute/worker/.env.example index fa18018..eef2843 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -30,8 +30,13 @@ S3_FORCE_PATH_STYLE=true # Queue + execution tuning COMPUTE_PREWARM_MODELS=true +COMPUTE_WHISPER_CONCURRENCY=1 +COMPUTE_PDF_CONCURRENCY=2 +COMPUTE_WHISPER_TIMEOUT_MS=30000 +COMPUTE_PDF_TIMEOUT_MS=90000 +COMPUTE_JOB_ATTEMPTS=2 COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 COMPUTE_JOB_STATES_MAX_BYTES=67108864 # Optional stale window for reusing in-flight opKey entries before forcing a new attempt # Default is max(30m, 4x max compute timeout); running jobs also refresh heartbeat every 5s -# COMPUTE_OP_STALE_MS=180000 +# COMPUTE_OP_STALE_MS=1800000 diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 1cbb1f1..caaccae 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -75,11 +75,14 @@ export default function PDFViewerPage() { const backNavTimeoutRef = useRef | null>(null); const clearCurrDocRef = useRef(clearCurrDoc); const [isNavigatingBack, setIsNavigatingBack] = useState(false); - const parseState = parseStatus ?? 'pending'; - const isParseReady = parseState === 'ready'; + const parseUiState: 'unknown' | NonNullable = parseStatus ?? 'unknown'; + const isParseReady = parseUiState === 'ready'; const forceReparseDisabled = isForceReparseDisabled(parseStatus); + const hasRealParseProgress = !!parseProgress + && parseProgress.totalPages > 0 + && parseProgress.pagesParsed >= 0; const shouldShowExpandedParseLoader = !isLoading - && (parseState === 'pending' || parseState === 'running' || parseState === 'failed'); + && (parseUiState === 'failed' || hasRealParseProgress); useEffect(() => { setIsLoading(true); @@ -283,10 +286,10 @@ export default function PDFViewerPage() { const renderPdfStatusLoader = () => { const compactLabel = isLoading ? 'Opening PDF...' - : (parseState === 'ready' ? 'Rendering pages...' : 'Checking parse state...'); + : (parseUiState === 'ready' ? 'Rendering pages...' : 'Checking parse state...'); const compactSubLabel = isLoading ? 'Loading document data' - : (parseState === 'ready' ? 'Preparing first frame' : 'Preparing layout parser'); + : (parseUiState === 'ready' ? 'Preparing first frame' : 'Waiting for parse status'); const totalPages = parseProgress?.totalPages ?? 0; const pagesParsed = parseProgress?.pagesParsed ?? 0; @@ -299,25 +302,30 @@ export default function PDFViewerPage() { let statusText = 'Loading PDF...'; let statusSubText = 'Initializing document renderer'; if (!isLoading) { - if (parseState === 'pending') { + if (parseUiState === 'unknown') { + statusText = 'Checking parse state...'; + statusSubText = 'Waiting for server status snapshot'; + } else if (parseUiState === 'pending') { statusText = 'Preparing PDF layout...'; statusSubText = parseProgress?.phase === 'merge' ? 'Finalizing stitched block structure' : 'Queueing parser and preparing page extraction'; - } else if (parseState === 'running') { + } else if (parseUiState === 'running') { statusText = 'Parsing PDF layout blocks...'; statusSubText = parseProgress?.phase === 'merge' ? 'Merging cross-page sections' : 'Inferring reading order and text regions'; - } else if (parseState === 'failed') { + } else if (parseUiState === 'failed') { statusText = 'PDF parsing failed. Retry to continue.'; statusSubText = 'The parser could not build a usable layout map'; } } - const stageLabel = parseState === 'failed' + const stageLabel = parseUiState === 'unknown' + ? 'Stage: checking' + : parseUiState === 'failed' ? 'Stage: blocked' - : (parseState === 'pending' + : (parseUiState === 'pending' ? 'Stage: prepare' : (isMerging ? 'Stage: merge' : 'Stage: infer')); @@ -354,7 +362,7 @@ export default function PDFViewerPage() {

- {!isLoading && parseState === 'failed' ? ( + {!isLoading && parseUiState === 'failed' ? (
) : null} {isLoading || !isParseReady || !isPdfViewerReady ? ( -
+
{renderPdfStatusLoader()}
) : null} diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts index f3e4504..21337f2 100644 --- a/tests/accessibility.spec.ts +++ b/tests/accessibility.spec.ts @@ -53,6 +53,7 @@ test.describe('Accessibility smoke', () => { }); test('TTS controls expose aria labels and are keyboard focusable', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.pdf'); // TTS bar present diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 00e6153..ce64437 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -271,7 +271,7 @@ async function resetAudiobookIfPresent(page: Page, bookId?: string) { } test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); // Ensure TTS is mocked and app is ready await setupTest(page, testInfo); @@ -382,7 +382,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async }); test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -413,6 +413,7 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }); test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -451,7 +452,7 @@ test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { }); test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -525,7 +526,7 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a }); test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); diff --git a/tests/helpers.ts b/tests/helpers.ts index c5b6da5..500b3dc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -24,17 +24,23 @@ function sha256HexOfFile(filePath: string) { async function waitForPdfViewerReady(page: Page, timeout = 60000) { await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) }); + const loader = page.getByTestId('pdf-status-loader').first(); + const parseFailedBanner = page.getByText('PDF parsing failed. Retry to continue.').first(); await expect .poll(async () => { + const parseFailed = await parseFailedBanner.isVisible().catch(() => false); + if (parseFailed) return 'failed'; + const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false); - if (documentVisible) return true; const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false); - if (pageVisible) return true; const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false); - if (canvasVisible) return true; - return false; + + const hasRenderedPdf = documentVisible || pageVisible || canvasVisible; + const loaderVisible = await loader.isVisible().catch(() => false); + if (hasRenderedPdf && !loaderVisible) return 'ready'; + return 'loading'; }, { timeout }) - .toBe(true); + .toBe('ready'); } /** @@ -396,7 +402,7 @@ export async function expectViewerForFile(page: Page, fileName: string) { if (lower.endsWith('.pdf') || lower.endsWith('.docx')) { // DOCX converts to PDF, so viewer expectations are PDF await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/); - await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 }); + await waitForPdfViewerReady(page, 60000); return; } if (lower.endsWith('.epub')) { diff --git a/tests/landing-routing.spec.ts b/tests/landing-routing.spec.ts index f9b4bb4..ca9347c 100644 --- a/tests/landing-routing.spec.ts +++ b/tests/landing-routing.spec.ts @@ -37,6 +37,7 @@ test.describe('Landing and app routing', () => { }); test('documents back link returns to /app', async ({ page }, testInfo) => { + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index 173a0c1..b642b20 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -37,6 +37,7 @@ test.describe('Document link navigation by type', () => { }); test('navigates to /pdf, /epub, /html and renders correct viewers', async ({ page }) => { + test.setTimeout(120_000); // Upload documents await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); @@ -65,10 +66,9 @@ test.describe('PDF view modes and Navigator', () => { }); test('switches Single/Dual/Scroll modes and uses Navigator to change page', async ({ page }) => { - test.setTimeout(60_000); + test.setTimeout(120_000); // Open PDF viewer await uploadAndDisplay(page, 'sample.pdf'); - await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 }); // Open document settings (page-level settings) await page.getByRole('button', { name: 'Open settings' }).click(); diff --git a/tests/play.spec.ts b/tests/play.spec.ts index e9a6b3a..aeaaf68 100644 --- a/tests/play.spec.ts +++ b/tests/play.spec.ts @@ -45,6 +45,7 @@ test.describe('Play/Pause Tests', () => { test.describe.configure({ mode: 'serial', timeout: 60000 }); test('plays and pauses TTS for a PDF document', async ({ page }) => { + test.setTimeout(120_000); // Play TTS for the PDF document await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -61,6 +62,7 @@ test.describe('Play/Pause Tests', () => { }); test('plays and pauses TTS for an DOCX document', async ({ page }) => { + test.setTimeout(120_000); // Play TTS for the DOCX document await playTTSAndWaitForASecond(page, 'sample.docx'); @@ -77,6 +79,7 @@ test.describe('Play/Pause Tests', () => { }); test('switches to a single voice and resumes playing', async ({ page }) => { + test.setTimeout(120_000); // Start playback await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -96,6 +99,7 @@ test.describe('Play/Pause Tests', () => { }); test('keeps selected single voice instead of resetting to first option', async ({ page }) => { + test.setTimeout(120_000); await playTTSAndWaitForASecond(page, 'sample.pdf'); await openVoicesMenu(page); @@ -122,6 +126,7 @@ test.describe('Play/Pause Tests', () => { }); if (!process.env.CI) test('selects multiple Kokoro voices and resumes playing', async ({ page }) => { + test.setTimeout(120_000); // Start playback await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -142,6 +147,7 @@ test.describe('Play/Pause Tests', () => { }); test('changing TTS native speed toggles processing and returns to playing', async ({ page }) => { + test.setTimeout(120_000); await playTTSAndWaitForASecond(page, 'sample.pdf'); await changeNativeSpeedAndAssert(page, 1.5); await expectMediaState(page, 'playing'); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index c43c7aa..7c66b73 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -88,10 +88,10 @@ test.describe('Document Upload Tests', () => { }); test('displays a PDF document', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.pdf'); await expectViewerForFile(page, 'sample.pdf'); // Additional content checks specific to the sample PDF - await expect(page.locator('.react-pdf__Page')).toBeVisible(); await expect(page.getByRole('heading', { level: 1, name: 'sample.pdf' })).toBeVisible(); await expect(page.getByRole('button', { name: /1\s*\/\s*2/ })).toBeVisible(); }); @@ -105,10 +105,10 @@ test.describe('Document Upload Tests', () => { }); test('displays a DOCX document as PDF after conversion', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.docx'); await expectViewerForFile(page, 'sample.docx'); // DOCX converts to PDF // Keep specific content checks - await expect(page.locator('.react-pdf__Page')).toBeVisible(); await expect(page.getByText('Demonstration of DOCX')).toBeVisible(); }); @@ -119,6 +119,7 @@ test.describe('Document Upload Tests', () => { }); test('uploads PDF/EPUB/TXT and opens correct viewer for each', async ({ page }) => { + test.setTimeout(120_000); // Upload multiple files await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); From 52083c6a09a842aa3f8016caec9c115a43404799 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 25 May 2026 12:00:31 -0600 Subject: [PATCH 090/137] build(config): add path alias for api-contracts in compute-core Update tsconfig.json to include a path mapping for @openreader/compute-core/api-contracts, enabling cleaner imports and improving modularity for API contract usage across the codebase. --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index d590d49..9279c29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "paths": { "@/*": ["./src/*"], "@openreader/compute-core": ["./compute/core/src/index.ts"], + "@openreader/compute-core/api-contracts": ["./compute/core/src/api-contracts/index.ts"], "@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"], "@openreader/compute-core/types": ["./compute/core/src/types/index.ts"] } From 2df49b54dea2c2e27f0bfcef3871bfea0303231c Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 25 May 2026 21:06:18 -0600 Subject: [PATCH 091/137] feat(parse-progress): add local in-memory realtime bus --- .../api/documents/[id]/parsed/events/route.ts | 166 +++++++++++++---- .../documents/parse-progress-bus.memory.ts | 36 ++++ .../server/documents/parse-progress-bus.ts | 16 ++ .../documents/parse-progress-bus.types.ts | 14 ++ .../server/documents/parse-progress-events.ts | 18 ++ .../server/documents/parse-state-healing.ts | 24 ++- src/lib/server/jobs/user-pdf-layout-job.ts | 173 +++++++++++++----- tests/unit/parse-progress-bus.spec.ts | 57 ++++++ 8 files changed, 425 insertions(+), 79 deletions(-) create mode 100644 src/lib/server/documents/parse-progress-bus.memory.ts create mode 100644 src/lib/server/documents/parse-progress-bus.ts create mode 100644 src/lib/server/documents/parse-progress-bus.types.ts create mode 100644 src/lib/server/documents/parse-progress-events.ts create mode 100644 tests/unit/parse-progress-bus.spec.ts diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index d48771c..44df139 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -3,16 +3,22 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; -import { isS3Configured } from '@/lib/server/storage/s3'; -import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { readComputeMode } from '@/lib/server/compute/mode'; +import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; +import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; const SSE_POLL_INTERVAL_MS = 1200; +const SSE_KEEPALIVE_MS = 15_000; +const SSE_RESYNC_INTERVAL_MS = 30_000; type ParseRow = { id: string; @@ -95,18 +101,132 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } const initial = await toSnapshot(row); + const computeMode = readComputeMode(); + const bus = computeMode === 'local' ? await getParseProgressBus() : null; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { let closed = false; + let unsubscribe: (() => void) | null = null; + let keepaliveTimer: ReturnType | null = null; + let resyncTimer: ReturnType | null = null; + + const allowedUserHashes = new Set(allowedUserIds.map((candidate) => hashUserId(candidate))); const writeSnapshot = (snapshot: ParsedSnapshot): void => { controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`)); }; - const run = async () => { + const closeStream = (): void => { + if (closed) return; + closed = true; + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + if (resyncTimer) { + clearInterval(resyncTimer); + resyncTimer = null; + } + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + try { + controller.close(); + } catch { + // no-op + } + }; + + const syncFromDb = async (input: { + signature: string; + }): Promise<{ snapshot: ParsedSnapshot; signature: string } | null> => { + const nextRow = await loadPreferredRow({ + documentId: id, + storageUserId, + allowedUserIds, + }); + if (!nextRow) return null; + + const nextSnapshot = await toSnapshot(nextRow); + const nextSignature = JSON.stringify(nextSnapshot); + if (nextSignature !== input.signature) { + writeSnapshot(nextSnapshot); + } + return { + snapshot: nextSnapshot, + signature: nextSignature, + }; + }; + + const runLocalRealtime = async () => { + let current = initial; + let signature = JSON.stringify(current); + writeSnapshot(current); + + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + return; + } + + keepaliveTimer = setInterval(() => { + if (closed) return; + controller.enqueue(encoder.encode(': keepalive\n\n')); + }, SSE_KEEPALIVE_MS); + + resyncTimer = setInterval(() => { + if (closed) return; + void syncFromDb({ signature }).then((next) => { + if (closed) return; + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + } + }).catch((error) => { + if (closed) return; + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + closeStream(); + }); + }, SSE_RESYNC_INTERVAL_MS); + + if (!bus) throw new Error('Local parse progress bus unavailable'); + const nextUnsubscribe = await bus.subscribe({ + documentId: id, + userIdHashes: allowedUserHashes, + onEvent: async () => { + const next = await syncFromDb({ signature }); + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + } + }, + onError: (error) => { + if (closed) return; + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + closeStream(); + }, + }); + if (closed) { + nextUnsubscribe(); + return; + } + unsubscribe = nextUnsubscribe; + }; + + const runWorkerPolling = async () => { let current = initial; writeSnapshot(current); let signature = JSON.stringify(current); @@ -116,24 +236,15 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string await sleep(SSE_POLL_INTERVAL_MS); if (closed) break; - const nextRow = await loadPreferredRow({ - documentId: id, - storageUserId, - allowedUserIds, - }); - if (!nextRow) break; - - const next = await toSnapshot(nextRow); - - const nextSignature = JSON.stringify(next); - if (nextSignature !== signature) { - current = next; - signature = nextSignature; - writeSnapshot(current); - } + const next = await syncFromDb({ signature }); + if (!next) break; + current = next.snapshot; + signature = next.signature; } }; + const run = computeMode === 'local' ? runLocalRealtime : runWorkerPolling; + void run() .catch((error) => { if (!closed) { @@ -141,24 +252,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } }) .finally(() => { - if (!closed) { - closed = true; - try { - controller.close(); - } catch { - // no-op - } - } + closeStream(); }); req.signal.addEventListener('abort', () => { - if (closed) return; - closed = true; - try { - controller.close(); - } catch { - // no-op - } + closeStream(); }, { once: true }); }, cancel() { diff --git a/src/lib/server/documents/parse-progress-bus.memory.ts b/src/lib/server/documents/parse-progress-bus.memory.ts new file mode 100644 index 0000000..9a707f1 --- /dev/null +++ b/src/lib/server/documents/parse-progress-bus.memory.ts @@ -0,0 +1,36 @@ +import { EventEmitter } from 'node:events'; +import type { ParseProgressBus, ParseProgressBusSubscribeInput } from '@/lib/server/documents/parse-progress-bus.types'; +import type { ParseProgressEvent } from '@/lib/server/documents/parse-progress-events'; + +type Listener = (event: ParseProgressEvent) => void; + +function topicForDocument(documentId: string): string { + return `parse-progress.${documentId}`; +} + +export class InMemoryParseProgressBus implements ParseProgressBus { + readonly kind = 'memory' as const; + private readonly emitter = new EventEmitter(); + + constructor() { + this.emitter.setMaxListeners(0); + } + + async publish(event: ParseProgressEvent): Promise { + this.emitter.emit(topicForDocument(event.documentId), event); + } + + async subscribe(input: ParseProgressBusSubscribeInput): Promise<() => void> { + const listener: Listener = (event) => { + if (!input.userIdHashes.has(event.userIdHash)) return; + Promise.resolve(input.onEvent(event)).catch((error) => { + input.onError?.(error); + }); + }; + const topic = topicForDocument(input.documentId); + this.emitter.on(topic, listener); + return () => { + this.emitter.off(topic, listener); + }; + } +} diff --git a/src/lib/server/documents/parse-progress-bus.ts b/src/lib/server/documents/parse-progress-bus.ts new file mode 100644 index 0000000..df6e27b --- /dev/null +++ b/src/lib/server/documents/parse-progress-bus.ts @@ -0,0 +1,16 @@ +import { InMemoryParseProgressBus } from '@/lib/server/documents/parse-progress-bus.memory'; +import type { ParseProgressBus } from '@/lib/server/documents/parse-progress-bus.types'; + +let busPromise: Promise | null = null; + +export async function getParseProgressBus(): Promise { + if (busPromise) return busPromise; + busPromise = Promise.resolve(new InMemoryParseProgressBus()); + return busPromise; +} + +export function __resetParseProgressBusForTests(): void { + busPromise = null; +} + +export type { ParseProgressBus, ParseProgressBusSubscribeInput } from '@/lib/server/documents/parse-progress-bus.types'; diff --git a/src/lib/server/documents/parse-progress-bus.types.ts b/src/lib/server/documents/parse-progress-bus.types.ts new file mode 100644 index 0000000..423d257 --- /dev/null +++ b/src/lib/server/documents/parse-progress-bus.types.ts @@ -0,0 +1,14 @@ +import type { ParseProgressEvent } from '@/lib/server/documents/parse-progress-events'; + +export interface ParseProgressBusSubscribeInput { + documentId: string; + userIdHashes: Set; + onEvent: (event: ParseProgressEvent) => void | Promise; + onError?: (error: unknown) => void; +} + +export interface ParseProgressBus { + readonly kind: 'memory'; + publish(event: ParseProgressEvent): Promise; + subscribe(input: ParseProgressBusSubscribeInput): Promise<() => void>; +} diff --git a/src/lib/server/documents/parse-progress-events.ts b/src/lib/server/documents/parse-progress-events.ts new file mode 100644 index 0000000..f43fc91 --- /dev/null +++ b/src/lib/server/documents/parse-progress-events.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; + +export interface ParseProgressEvent { + version: 1; + documentId: string; + userIdHash: string; + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + updatedAt: number; + opId?: string; + jobId?: string; + error?: string | null; +} + +export function hashUserId(userId: string): string { + return createHash('sha256').update(userId).digest('hex').slice(0, 24); +} diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts index b7a946c..909bbe3 100644 --- a/src/lib/server/documents/parse-state-healing.ts +++ b/src/lib/server/documents/parse-state-healing.ts @@ -8,6 +8,8 @@ import { stringifyDocumentParseState, type DocumentParseState, } from '@/lib/server/documents/parse-state'; +import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; +import { hashUserId } from '@/lib/server/documents/parse-progress-events'; export async function healStaleDocumentParseState(input: { documentId: string; @@ -29,6 +31,26 @@ export async function healStaleDocumentParseState(input: { .set({ parseState: stringifyDocumentParseState(nextState) }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + try { + const bus = await getParseProgressBus(); + await bus.publish({ + version: 1, + documentId: input.documentId, + userIdHash: hashUserId(input.userId), + parseStatus: nextState.status, + parseProgress: nextState.progress ?? null, + updatedAt: nextState.updatedAt ?? Date.now(), + ...(nextState.opId ? { opId: nextState.opId } : {}), + ...(nextState.jobId ? { jobId: nextState.jobId } : {}), + ...(nextState.error ? { error: nextState.error } : {}), + }); + } catch (error) { + console.warn('[parse-state-healing] failed to publish stale state event', { + documentId: input.documentId, + userId: input.userId, + error: error instanceof Error ? error.message : String(error), + }); + } + return nextState; } - diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index deec00e..6a40d94 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -3,7 +3,13 @@ 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 { parseDocumentParseState, stringifyDocumentParseState } from '@/lib/server/documents/parse-state'; +import { + parseDocumentParseState, + stringifyDocumentParseState, + type DocumentParseState, +} from '@/lib/server/documents/parse-state'; +import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; +import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; @@ -110,6 +116,41 @@ async function updateParseStateForUsers(input: { ); } +function normalizeEventState(state: DocumentParseState): DocumentParseState { + return parseDocumentParseState(stringifyDocumentParseState(state)); +} + +async function emitParseStateEvents(input: { + documentId: string; + userIds: string[]; + state: DocumentParseState; +}): Promise { + if (input.userIds.length === 0) return; + const normalized = normalizeEventState(input.state); + const bus = await getParseProgressBus(); + + const publishResults = await Promise.allSettled(input.userIds.map(async (userId) => bus.publish({ + version: 1, + documentId: input.documentId, + userIdHash: hashUserId(userId), + parseStatus: normalized.status, + parseProgress: normalized.progress ?? null, + updatedAt: normalized.updatedAt ?? Date.now(), + ...(normalized.opId ? { opId: normalized.opId } : {}), + ...(normalized.jobId ? { jobId: normalized.jobId } : {}), + ...(normalized.error ? { error: normalized.error } : {}), + }))); + + publishResults.forEach((result, index) => { + if (result.status === 'fulfilled') return; + console.warn('[parsePdfJob] failed to publish parse progress event', { + documentId: input.documentId, + userId: input.userIds[index], + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + }); +} + async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise { const deadline = Date.now() + FOLLOWER_WAIT_TIMEOUT_MS; @@ -117,16 +158,22 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise const rows = await loadScopedRows(input); const ready = rows.find(isReadyRow); if (ready) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; await updateParseStateForUsers({ documentId: input.documentId, userIds: [input.userId], - parseState: stringifyDocumentParseState({ - status: 'ready', - progress: null, - updatedAt: Date.now(), - }), + parseState: stringifyDocumentParseState(readyState), parsedJsonKey: ready.parsedJsonKey, }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: [input.userId], + state: readyState, + }); return; } @@ -134,15 +181,21 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise const hasInFlight = statuses.some((state) => state.status === 'pending' || state.status === 'running'); const failedState = statuses.find((state) => state.status === 'failed'); if (failedState && !hasInFlight) { + const nextFailedState: DocumentParseState = { + status: 'failed', + progress: null, + updatedAt: Date.now(), + ...(failedState.error ? { error: failedState.error } : {}), + }; await updateParseStateForUsers({ documentId: input.documentId, userIds: [input.userId], - parseState: stringifyDocumentParseState({ - status: 'failed', - progress: null, - updatedAt: Date.now(), - ...(failedState.error ? { error: failedState.error } : {}), - }), + parseState: stringifyDocumentParseState(nextFailedState), + }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: [input.userId], + state: nextFailedState, }); return; } @@ -170,16 +223,22 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise if (!input.forceToken?.trim()) { const ready = scopedRows.find(isReadyRow); if (ready) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; await updateParseStateForUsers({ documentId: input.documentId, userIds: [input.userId], - parseState: stringifyDocumentParseState({ - status: 'ready', - progress: null, - updatedAt: Date.now(), - }), + parseState: stringifyDocumentParseState(readyState), parsedJsonKey: ready.parsedJsonKey, }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: [input.userId], + state: readyState, + }); return; } } @@ -188,12 +247,13 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise if (!coordinator) return; const claimOpId = randomUUID(); - const runningState = stringifyDocumentParseState({ + const runningStateData: DocumentParseState = { status: 'running', progress: null, updatedAt: Date.now(), opId: claimOpId, - }); + }; + const runningState = stringifyDocumentParseState(runningStateData); const claimRows = (await db .update(documents) @@ -223,23 +283,34 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise userIds: cohortUserIds, parseState: runningState, }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: cohortUserIds, + state: runningStateData, + }); const compute = await getCompute(); const writeProgress = async (progress: PdfLayoutProgress): Promise => { + const runningProgressState: DocumentParseState = { + status: 'running', + progress: { + totalPages: progress.totalPages, + pagesParsed: progress.pagesParsed, + currentPage: progress.currentPage, + phase: progress.phase, + }, + updatedAt: Date.now(), + opId: claimOpId, + }; await updateParseStateForUsers({ documentId: input.documentId, userIds: cohortUserIds, - parseState: stringifyDocumentParseState({ - status: 'running', - progress: { - totalPages: progress.totalPages, - pagesParsed: progress.pagesParsed, - currentPage: progress.currentPage, - phase: progress.phase, - }, - updatedAt: Date.now(), - opId: claimOpId, - }), + parseState: stringifyDocumentParseState(runningProgressState), + }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: cohortUserIds, + state: runningProgressState, }); }; const layout = await compute.parsePdfLayout({ @@ -259,19 +330,26 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise const finalScopedRows = await loadScopedRows(input); const finalUserIds = userIdsFromRows(finalScopedRows); + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; + const readyUserIds = finalUserIds.length > 0 ? finalUserIds : cohortUserIds; await updateParseStateForUsers({ documentId: input.documentId, - userIds: finalUserIds.length > 0 ? finalUserIds : cohortUserIds, - parseState: stringifyDocumentParseState({ - status: 'ready', - progress: null, - updatedAt: Date.now(), - }), + userIds: readyUserIds, + parseState: stringifyDocumentParseState(readyState), parsedJsonKey, }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: readyUserIds, + state: readyState, + }); // Best-effort cache invalidation should not block parse readiness. - for (const userId of (finalUserIds.length > 0 ? finalUserIds : cohortUserIds)) { + for (const userId of readyUserIds) { void clearTtsSegmentCache({ userId, documentId: input.documentId, @@ -300,15 +378,22 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise try { const scopedRows = await loadScopedRows(input); const scopedUserIds = userIdsFromRows(scopedRows); + const failedState: DocumentParseState = { + status: 'failed', + progress: null, + updatedAt: Date.now(), + error: message, + }; + const failedUserIds = scopedUserIds.length > 0 ? scopedUserIds : [input.userId]; await updateParseStateForUsers({ documentId: input.documentId, - userIds: scopedUserIds.length > 0 ? scopedUserIds : [input.userId], - parseState: stringifyDocumentParseState({ - status: 'failed', - progress: null, - updatedAt: Date.now(), - error: message, - }), + userIds: failedUserIds, + parseState: stringifyDocumentParseState(failedState), + }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: failedUserIds, + state: failedState, }); } catch (statusError) { console.error('[parsePdfJob] failed to write parse status', { diff --git a/tests/unit/parse-progress-bus.spec.ts b/tests/unit/parse-progress-bus.spec.ts new file mode 100644 index 0000000..754c71d --- /dev/null +++ b/tests/unit/parse-progress-bus.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { __resetParseProgressBusForTests, getParseProgressBus } from '../../src/lib/server/documents/parse-progress-bus'; +import { InMemoryParseProgressBus } from '../../src/lib/server/documents/parse-progress-bus.memory'; +import { hashUserId, type ParseProgressEvent } from '../../src/lib/server/documents/parse-progress-events'; + +test.describe('parse progress bus', () => { + test.afterEach(() => { + __resetParseProgressBusForTests(); + }); + + test('uses in-memory singleton bus', async () => { + const busA = await getParseProgressBus(); + const busB = await getParseProgressBus(); + expect(busA.kind).toBe('memory'); + expect(busA).toBe(busB); + }); + + test('in-memory bus publishes only to subscribed user hashes', async () => { + const bus = new InMemoryParseProgressBus(); + const documentId = 'd'.repeat(64); + const wantedHash = hashUserId('user-a'); + const otherHash = hashUserId('user-b'); + const received: ParseProgressEvent[] = []; + + const close = await bus.subscribe({ + documentId, + userIdHashes: new Set([wantedHash]), + onEvent: (event) => { + received.push(event); + }, + }); + + await bus.publish({ + version: 1, + documentId, + userIdHash: otherHash, + parseStatus: 'running', + parseProgress: { totalPages: 4, pagesParsed: 1, currentPage: 2, phase: 'infer' }, + updatedAt: Date.now(), + }); + + await bus.publish({ + version: 1, + documentId, + userIdHash: wantedHash, + parseStatus: 'running', + parseProgress: { totalPages: 4, pagesParsed: 2, currentPage: 3, phase: 'infer' }, + updatedAt: Date.now(), + opId: 'op-1', + }); + + close(); + expect(received).toHaveLength(1); + expect(received[0].userIdHash).toBe(wantedHash); + expect(received[0].opId).toBe('op-1'); + }); +}); From be7ec2c254aec29127464a5b4caf70ef170507a9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 25 May 2026 21:37:27 -0600 Subject: [PATCH 092/137] feat(worker-events): stream op events via jetstream and proxy in app --- compute/core/src/api-contracts/index.ts | 5 + compute/worker/src/server.ts | 136 ++++++- .../api/documents/[id]/parsed/events/route.ts | 334 ++++++++++++++++-- src/lib/server/compute/types.ts | 2 + src/lib/server/compute/worker.ts | 256 ++++++++------ src/lib/server/jobs/user-pdf-layout-job.ts | 88 ++++- 6 files changed, 650 insertions(+), 171 deletions(-) diff --git a/compute/core/src/api-contracts/index.ts b/compute/core/src/api-contracts/index.ts index ff4b2a0..18faa08 100644 --- a/compute/core/src/api-contracts/index.ts +++ b/compute/core/src/api-contracts/index.ts @@ -113,3 +113,8 @@ export interface WorkerOperationState { timing?: WorkerJobTiming; progress?: PdfLayoutProgress; } + +export interface WorkerOperationEvent { + eventId: number; + snapshot: WorkerOperationState; +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 0b3a381..ea30c8b 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -37,6 +37,7 @@ import { import type { PdfLayoutJobRequest, PdfLayoutJobResult, + WorkerOperationEvent, WhisperAlignJobRequest, WhisperAlignJobResult, WorkerJobErrorShape, @@ -54,11 +55,12 @@ const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; const LAYOUT_JOBS_SUBJECT = 'jobs.layout'; const WHISPER_CONSUMER_NAME = 'compute_whisper'; const LAYOUT_CONSUMER_NAME = 'compute_layout'; +const EVENTS_STREAM_NAME = 'compute_events'; const COMPUTE_STATE_BUCKET = 'compute_state'; const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000; const LOOP_ERROR_BACKOFF_MS = 500; -const SSE_POLL_INTERVAL_MS = 400; const RUNNING_HEARTBEAT_MS = 5000; +const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const WHISPER_MAX_DELIVER = 1; @@ -115,6 +117,8 @@ interface NatsSession { layoutConsumer: Consumer; } +type StreamedOperationState = WorkerOperationState; + type JsonCodec = { encode(value: T): Uint8Array; decode(data: Uint8Array): T; @@ -363,6 +367,10 @@ function jobStateKvKey(jobId: string): string { return `job_state.${jobId}`; } +function opEventsSubject(opId: string): string { + return `${OP_EVENTS_SUBJECT_PREFIX}.${opId}`; +} + function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; const maybe = result as { parsedObjectKey?: unknown }; @@ -400,7 +408,8 @@ async function ensureJetStreamResources( whisperTimeoutMs: number, pdfTimeoutMs: number, pdfAttempts: number, - maxBytes: number, + jobsMaxBytes: number, + eventsMaxBytes: number, natsReplicas: number, ): Promise { const streamConfig = { @@ -408,7 +417,7 @@ async function ensureJetStreamResources( subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], retention: RetentionPolicy.Workqueue, storage: StorageType.File, - max_bytes: maxBytes, + max_bytes: jobsMaxBytes, num_replicas: natsReplicas, }; @@ -418,7 +427,29 @@ async function ensureJetStreamResources( if (!isAlreadyExistsError(error)) throw error; await jsm.streams.update(JOBS_STREAM_NAME, { subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], - max_bytes: maxBytes, + max_bytes: jobsMaxBytes, + num_replicas: natsReplicas, + }); + } + + const eventsStreamConfig = { + name: EVENTS_STREAM_NAME, + subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`], + retention: RetentionPolicy.Limits, + storage: StorageType.File, + max_bytes: eventsMaxBytes, + max_age: nanos(COMPUTE_STATE_TTL_MS), + num_replicas: natsReplicas, + }; + + try { + await jsm.streams.add(eventsStreamConfig); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.streams.update(EVENTS_STREAM_NAME, { + subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`], + max_bytes: eventsMaxBytes, + max_age: nanos(COMPUTE_STATE_TTL_MS), num_replicas: natsReplicas, }); } @@ -471,6 +502,7 @@ async function main(): Promise { const pdfAttempts = readIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1); const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024); + const eventsStreamMaxBytes = readIntEnv('COMPUTE_EVENTS_STREAM_MAX_BYTES', 128 * 1024 * 1024); const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024); const natsReplicas = normalizeNatsReplicas(readIntEnv('COMPUTE_NATS_REPLICAS', 1)); const opStaleMs = getComputeOpStaleMs(); @@ -555,7 +587,15 @@ async function main(): Promise { const nc: NatsConnection = await connect(connectOpts); const js: JetStreamClient = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS }); const jsm: JetStreamManager = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS }); - await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, pdfAttempts, jobsStreamMaxBytes, natsReplicas); + await ensureJetStreamResources( + jsm, + whisperTimeoutMs, + pdfTimeoutMs, + pdfAttempts, + jobsStreamMaxBytes, + eventsStreamMaxBytes, + natsReplicas, + ); const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, { replicas: natsReplicas, history: 1, @@ -638,21 +678,32 @@ async function main(): Promise { onnxThreadsPerJob: getOnnxThreadsPerJob(), natsApiTimeoutMs: NATS_API_TIMEOUT_MS, natsReplicas, + eventsStreamMaxBytes, pdfLayoutHardCapMs: pdfHardCapMs, }, 'compute runtime config'); const opIndexCodec = createJsonCodec(); - const opStateCodec = createJsonCodec>(); + const opStateCodec = createJsonCodec(); const whisperJobCodec = createJsonCodec>(); const layoutJobCodec = createJsonCodec>(); const jobStateCodec = createJsonCodec>(); + const opEventCodec = createJsonCodec(); - const putOpState = async (state: WorkerOperationState): Promise => { - const { kv } = await ensureConnected(); + const putOpState = async (state: StreamedOperationState): Promise => { + const { kv, js } = await ensureConnected(); await kv.put(opStateKvKey(state.opId), opStateCodec.encode(state)); + try { + await js.publish(opEventsSubject(state.opId), opEventCodec.encode(state)); + } catch (error) { + app.log.warn({ + opId: state.opId, + status: state.status, + error: toErrorMessage(error), + }, 'failed to publish op event'); + } }; - const getOpState = async (opId: string): Promise | null> => { + const getOpState = async (opId: string): Promise => { const { kv } = await ensureConnected(); const entry = await kv.get(opStateKvKey(opId)); if (!entry || entry.operation !== 'PUT') return null; @@ -1007,6 +1058,18 @@ async function main(): Promise { return { error: 'Operation not found' }; } + const cursorQueryRaw = request.query as { sinceEventId?: string | number | null } | undefined; + const cursorFromQuery = Number(cursorQueryRaw?.sinceEventId ?? 0); + const lastEventIdHeader = request.headers['last-event-id']; + const cursorFromHeader = Number( + Array.isArray(lastEventIdHeader) ? (lastEventIdHeader[0] ?? 0) : (lastEventIdHeader ?? 0), + ); + const sinceEventId = Math.max( + 0, + Number.isFinite(cursorFromQuery) ? Math.floor(cursorFromQuery) : 0, + Number.isFinite(cursorFromHeader) ? Math.floor(cursorFromHeader) : 0, + ); + reply.hijack(); // onResponse will not fire for a hijacked reply, so release the HTTP in-flight // count here and track the long-lived stream via activeSse instead. @@ -1018,12 +1081,18 @@ async function main(): Promise { reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); - const writeSnapshot = (snapshot: WorkerOperationState): void => { + const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => { if (closed || reply.raw.writableEnded) return; - reply.raw.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`); + const frameEvent: WorkerOperationEvent = { + eventId, + snapshot, + }; + reply.raw.write(`id: ${eventId}\nevent: snapshot\ndata: ${JSON.stringify(frameEvent)}\n\n`); }; let closed = false; + let consumerName: string | null = null; + let messages: Awaited> | null = null; request.raw.on('close', () => { closed = true; activeSse = Math.max(0, activeSse - 1); @@ -1032,25 +1101,54 @@ async function main(): Promise { try { let current = initial; - writeSnapshot(current); let signature = JSON.stringify(current); + writeSnapshot(current, sinceEventId > 0 ? sinceEventId : 0); + if (isTerminalStatus(current.status)) { + return reply; + } - while (!closed && !isTerminalStatus(current.status)) { - await sleep(SSE_POLL_INTERVAL_MS); - const next = await getOpState(params.data.opId); - if (!next) break; - const nextSignature = JSON.stringify(next); + const { jsm, js } = await ensureConnected(); + const consumerConfig = { + name: `op_events_${params.data.opId.slice(0, 12)}_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`, + ack_policy: AckPolicy.None, + deliver_policy: sinceEventId > 0 ? DeliverPolicy.StartSequence : DeliverPolicy.New, + replay_policy: ReplayPolicy.Instant, + filter_subject: opEventsSubject(params.data.opId), + max_deliver: 1, + inactive_threshold: nanos(60_000_000_000), + ...(sinceEventId > 0 ? { opt_start_seq: sinceEventId + 1 } : {}), + }; + const info = await jsm.consumers.add(EVENTS_STREAM_NAME, consumerConfig); + consumerName = info.name; + const consumer = await js.consumers.get(EVENTS_STREAM_NAME, info.name); + messages = await consumer.consume(); + + for await (const msg of messages) { + if (closed) break; + const state = opEventCodec.decode(msg.data); + if (state.opId !== params.data.opId) continue; + + const nextSignature = JSON.stringify(state); if (nextSignature !== signature) { - current = next; + current = state; signature = nextSignature; - writeSnapshot(current); + writeSnapshot(current, msg.seq); } + if (isTerminalStatus(state.status)) break; } } catch (error) { app.log.warn({ opId: params.data.opId, error: toErrorMessage(error), }, 'op events stream loop error'); + } finally { + if (messages) { + await messages.close().catch(() => undefined); + } + if (consumerName) { + const { jsm } = await ensureConnected(); + await jsm.consumers.delete(EVENTS_STREAM_NAME, consumerName).catch(() => undefined); + } } if (!reply.raw.writableEnded) { diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 44df139..536b509 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { readComputeMode } from '@/lib/server/compute/mode'; +import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; @@ -12,6 +13,7 @@ import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state- import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; @@ -31,6 +33,11 @@ type ParsedSnapshot = { parseProgress: PdfParseProgress | null; }; +type SnapshotState = { + snapshot: ParsedSnapshot; + opId: string | null; +}; + function s3NotConfiguredResponse(): NextResponse { return NextResponse.json( { error: 'Documents storage is not configured. Set S3_* environment variables.' }, @@ -42,7 +49,53 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function toSnapshot(row: ParseRow): Promise { +function parseSsePayload(frame: string): string | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + const dataLines: string[] = []; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + dataLines.push(line.slice('data:'.length).trimStart()); + } + if (dataLines.length === 0) return null; + return dataLines.join('\n'); +} + +function parseSseEventId(frame: string): number | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + for (const line of lines) { + if (!line.startsWith('id:')) continue; + const value = Number(line.slice('id:'.length).trim()); + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + } + return null; +} + +function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { + switch (status) { + case 'queued': + return 'pending'; + case 'running': + return 'running'; + case 'succeeded': + return 'ready'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +} + +function snapshotFromWorkerState(state: WorkerOperationState): ParsedSnapshot { + const parseStatus = mapWorkerStatusToParseStatus(state.status); + return { + parseStatus, + parseProgress: parseStatus === 'running' ? (state.progress ?? null) : null, + }; +} + +async function toSnapshotState(row: ParseRow): Promise { const state = await healStaleDocumentParseState({ documentId: row.id, userId: row.userId, @@ -50,8 +103,11 @@ async function toSnapshot(row: ParseRow): Promise { }); const parseStatus = normalizeParseStatus(state.status); return { - parseStatus, - parseProgress: state.progress ?? null, + snapshot: { + parseStatus, + parseProgress: state.progress ?? null, + }, + opId: typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null, }; } @@ -72,6 +128,33 @@ async function loadPreferredRow(input: { return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null; } +async function syncFromDb(input: { + documentId: string; + storageUserId: string; + allowedUserIds: string[]; + signature: string; + writeSnapshot: (snapshot: ParsedSnapshot) => void; +}): Promise<{ snapshot: ParsedSnapshot; opId: string | null; signature: string } | null> { + const nextRow = await loadPreferredRow({ + documentId: input.documentId, + storageUserId: input.storageUserId, + allowedUserIds: input.allowedUserIds, + }); + if (!nextRow) return null; + + const nextState = await toSnapshotState(nextRow); + const nextSignature = JSON.stringify(nextState.snapshot); + if (nextSignature !== input.signature) { + input.writeSnapshot(nextState.snapshot); + } + + return { + snapshot: nextState.snapshot, + opId: nextState.opId, + signature: nextSignature, + }; +} + export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -100,10 +183,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const initial = await toSnapshot(row); + const initialState = await toSnapshotState(row); const computeMode = readComputeMode(); const bus = computeMode === 'local' ? await getParseProgressBus() : null; + const workerCfg = computeMode === 'worker' ? getWorkerClientConfigFromEnv() : null; const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -112,6 +196,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string let unsubscribe: (() => void) | null = null; let keepaliveTimer: ReturnType | null = null; let resyncTimer: ReturnType | null = null; + let workerAbort: AbortController | null = null; const allowedUserHashes = new Set(allowedUserIds.map((candidate) => hashUserId(candidate))); @@ -134,6 +219,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string unsubscribe(); unsubscribe = null; } + if (workerAbort) { + workerAbort.abort(); + workerAbort = null; + } try { controller.close(); } catch { @@ -141,29 +230,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } }; - const syncFromDb = async (input: { - signature: string; - }): Promise<{ snapshot: ParsedSnapshot; signature: string } | null> => { - const nextRow = await loadPreferredRow({ - documentId: id, - storageUserId, - allowedUserIds, - }); - if (!nextRow) return null; - - const nextSnapshot = await toSnapshot(nextRow); - const nextSignature = JSON.stringify(nextSnapshot); - if (nextSignature !== input.signature) { - writeSnapshot(nextSnapshot); - } - return { - snapshot: nextSnapshot, - signature: nextSignature, - }; - }; - const runLocalRealtime = async () => { - let current = initial; + let current = initialState.snapshot; let signature = JSON.stringify(current); writeSnapshot(current); @@ -179,7 +247,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string resyncTimer = setInterval(() => { if (closed) return; - void syncFromDb({ signature }).then((next) => { + void syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + writeSnapshot, + }).then((next) => { if (closed) return; if (!next) { closeStream(); @@ -202,7 +276,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string documentId: id, userIdHashes: allowedUserHashes, onEvent: async () => { - const next = await syncFromDb({ signature }); + const next = await syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + writeSnapshot, + }); if (!next) { closeStream(); return; @@ -226,24 +306,204 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string unsubscribe = nextUnsubscribe; }; - const runWorkerPolling = async () => { - let current = initial; - writeSnapshot(current); + const runWorkerProxy = async () => { + if (!workerCfg) throw new Error('Worker mode selected but worker config is unavailable'); + + let current = initialState.snapshot; let signature = JSON.stringify(current); + let currentOpId = initialState.opId; + let lastEventId: number | null = null; + + writeSnapshot(current); + + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + return; + } + + keepaliveTimer = setInterval(() => { + if (closed) return; + controller.enqueue(encoder.encode(': keepalive\n\n')); + }, SSE_KEEPALIVE_MS); + + resyncTimer = setInterval(() => { + if (closed) return; + void syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + writeSnapshot, + }).then((next) => { + if (closed) return; + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + if (next.opId !== currentOpId) { + currentOpId = next.opId; + lastEventId = null; + if (workerAbort) { + workerAbort.abort(); + workerAbort = null; + } + } + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + } + }).catch((error) => { + if (closed) return; + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + }); + }, SSE_RESYNC_INTERVAL_MS); while (!closed) { - if (current.parseStatus === 'ready' || current.parseStatus === 'failed') break; - await sleep(SSE_POLL_INTERVAL_MS); - if (closed) break; + if (!currentOpId) { + await sleep(SSE_POLL_INTERVAL_MS); + const next = await syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + writeSnapshot, + }); + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + currentOpId = next.opId; + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + return; + } + continue; + } - const next = await syncFromDb({ signature }); - if (!next) break; + workerAbort = new AbortController(); + const query = lastEventId && lastEventId > 0 + ? `?sinceEventId=${encodeURIComponent(String(lastEventId))}` + : ''; + const response = await fetch( + `${workerCfg.baseUrl}/ops/${encodeURIComponent(currentOpId)}/events${query}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${workerCfg.token}`, + Accept: 'text/event-stream', + ...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}), + }, + cache: 'no-store', + signal: workerAbort.signal, + }, + ); + + if (closed) return; + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + console.warn('[parsed/events] worker stream request failed', { + documentId: id, + opId: currentOpId, + status: response.status, + detail, + }); + await sleep(500); + continue; + } + if (!response.body) { + await sleep(500); + continue; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let streamEnded = false; + + while (!closed && !streamEnded) { + const read = await reader.read(); + if (read.done) { + streamEnded = true; + break; + } + + buffer += decoder.decode(read.value, { stream: true }); + + while (true) { + const frameEnd = buffer.indexOf('\n\n'); + if (frameEnd < 0) break; + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + + const eventId = parseSseEventId(frame); + if (eventId && eventId > 0) { + lastEventId = eventId; + } + + const payload = parseSsePayload(frame); + if (!payload) continue; + + let parsed: WorkerOperationEvent | WorkerOperationState; + try { + parsed = JSON.parse(payload) as WorkerOperationEvent | WorkerOperationState; + } catch { + continue; + } + + const workerSnapshot: WorkerOperationState = ( + parsed && typeof parsed === 'object' && 'snapshot' in parsed + ? parsed.snapshot + : parsed as WorkerOperationState + ); + if (!workerSnapshot || workerSnapshot.opId !== currentOpId) continue; + + const nextSnapshot = snapshotFromWorkerState(workerSnapshot); + const nextSignature = JSON.stringify(nextSnapshot); + if (nextSignature !== signature) { + current = nextSnapshot; + signature = nextSignature; + writeSnapshot(current); + } + + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + return; + } + } + } + + if (closed) return; + + const next = await syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + writeSnapshot, + }); + if (!next) { + closeStream(); + return; + } current = next.snapshot; signature = next.signature; + if (next.opId !== currentOpId) { + currentOpId = next.opId; + lastEventId = null; + } + if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { + closeStream(); + return; + } + await sleep(250); } }; - const run = computeMode === 'local' ? runLocalRealtime : runWorkerPolling; + const run = computeMode === 'local' ? runLocalRealtime : runWorkerProxy; void run() .catch((error) => { diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index ab178ac..f93a8f3 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -4,6 +4,7 @@ import type { ParsedPdfDocument, } from '@openreader/compute-core/types'; import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts'; +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; export type ComputeMode = 'local' | 'worker'; @@ -23,6 +24,7 @@ export interface PdfLayoutInput { pdfBytes?: ArrayBuffer; forceToken?: string; onProgress?: (progress: PdfLayoutProgress) => void | Promise; + onWorkerSnapshot?: (snapshot: WorkerOperationState) => void | Promise; } export type PdfLayoutResult = diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 0113fe1..10d4dc2 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -4,6 +4,7 @@ import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core'; import type { PdfLayoutJobRequest, PdfLayoutJobResult, + WorkerOperationEvent, WhisperAlignJobRequest, WhisperAlignJobResult, WorkerOperationState, @@ -118,6 +119,13 @@ function normalizeWorkerBaseUrl(raw: string): string { return parsed.toString().replace(/\/+$/, ''); } +export function getWorkerClientConfigFromEnv(): { baseUrl: string; token: string } { + return { + baseUrl: normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')), + token: readRequiredEnv('COMPUTE_WORKER_TOKEN'), + }; +} + function parseRetryAfterMs(value: string | null): number | null { if (!value) return null; const asNum = Number(value); @@ -189,6 +197,18 @@ function extractSsePayload(frame: string): string | null { return dataLines.join('\n'); } +function extractSseId(frame: string): number | null { + const normalized = frame.replace(/\r\n/g, '\n'); + for (const line of normalized.split('\n')) { + if (!line.startsWith('id:')) continue; + const value = Number(line.slice('id:'.length).trim()); + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + } + return null; +} + type RetryMeta = { attempt: number, maxAttempts: number, @@ -240,8 +260,9 @@ export class WorkerComputeBackend implements ComputeBackend { private readonly retries: number; constructor() { - this.baseUrl = normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')); - this.token = readRequiredEnv('COMPUTE_WORKER_TOKEN'); + const cfg = getWorkerClientConfigFromEnv(); + this.baseUrl = cfg.baseUrl; + this.token = cfg.token; this.waitTimeoutMsByKind = { whisper_align: getWorkerClientWaitTimeoutMs('whisper_align'), pdf_layout: getWorkerClientWaitTimeoutMs('pdf_layout'), @@ -371,6 +392,7 @@ export class WorkerComputeBackend implements ComputeBackend { documentId: input.documentId, attempt, }); + await input.onWorkerSnapshot?.(op); const final = isTerminalStatus(op.status) ? op @@ -382,6 +404,7 @@ export class WorkerComputeBackend implements ComputeBackend { attempt, waitTimeoutMs: this.waitTimeoutMsByKind.pdf_layout, onSnapshot: (snapshot) => { + void input.onWorkerSnapshot?.(snapshot); if (snapshot.progress) { void input.onProgress?.(snapshot.progress); } @@ -513,123 +536,150 @@ export class WorkerComputeBackend implements ComputeBackend { }); try { - const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.token}`, - Accept: 'text/event-stream', - 'x-openreader-trace-id': traceId, - }, - signal: controller.signal, - }); + let latest: WorkerOperationState | null = null; + let lastEventId: number | null = null; + let eventCount = 0; + let lastStatus: string | null = null; - if (!res.ok) { - const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); - const detail = await res.text().catch(() => ''); - logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', { + while (!controller.signal.aborted) { + const query = lastEventId && lastEventId > 0 + ? `?sinceEventId=${encodeURIComponent(String(lastEventId))}` + : ''; + const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events${query}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'text/event-stream', + 'x-openreader-trace-id': traceId, + ...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}), + }, + signal: controller.signal, + }); + + if (!res.ok) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const detail = await res.text().catch(() => ''); + logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', { + ...context, + traceId, + opId, + status: res.status, + retryAfterMs, + durationMs: Date.now() - startedAt, + detail: truncateForLog(detail), + }); + throw new WorkerHttpError( + `Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${detail ? ` ${detail}` : ''}`, + res.status, + retryAfterMs, + ); + } + + if (!res.body) { + logWorker('error', 'sse.wait.no_body', { + ...context, + traceId, + opId, + durationMs: Date.now() - startedAt, + }); + throw new Error('Worker operation stream response has no body'); + } + + logWorker('info', 'sse.wait.connected', { ...context, traceId, opId, status: res.status, - retryAfterMs, - durationMs: Date.now() - startedAt, - detail: truncateForLog(detail), }); - throw new WorkerHttpError( - `Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${detail ? ` ${detail}` : ''}`, - res.status, - retryAfterMs, - ); - } - - if (!res.body) { - logWorker('error', 'sse.wait.no_body', { - ...context, - traceId, - opId, - durationMs: Date.now() - startedAt, - }); - throw new Error('Worker operation stream response has no body'); - } - - logWorker('info', 'sse.wait.connected', { - ...context, - traceId, - opId, - status: res.status, - }); - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let latest: WorkerOperationState | null = null; - let eventCount = 0; - let lastStatus: string | null = null; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; while (true) { - const frameEnd = buffer.indexOf('\n\n'); - if (frameEnd < 0) break; - const frame = buffer.slice(0, frameEnd); - buffer = buffer.slice(frameEnd + 2); + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); - const payload = extractSsePayload(frame); - if (!payload) continue; + while (true) { + const frameEnd = buffer.indexOf('\n\n'); + if (frameEnd < 0) break; + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); - let snapshot: WorkerOperationState; - try { - snapshot = JSON.parse(payload) as WorkerOperationState; - } catch { - logWorker('warn', 'sse.wait.json_parse_skipped', { - ...context, - traceId, - opId, - sample: truncateForLog(payload), - }); - continue; - } + const eventId = extractSseId(frame); + if (eventId && eventId > 0) { + lastEventId = eventId; + } + const payload = extractSsePayload(frame); + if (!payload) continue; - eventCount += 1; - latest = snapshot; - context.onSnapshot?.(snapshot); - if (snapshot.status !== lastStatus) { - lastStatus = snapshot.status; - logWorker('info', 'sse.wait.status', { - ...context, - traceId, - opId, - eventCount, - status: snapshot.status, - jobId: snapshot.jobId ?? null, - updatedAt: snapshot.updatedAt ?? null, - }); - } - if (isTerminalStatus(snapshot.status)) { - logWorker('info', 'sse.wait.terminal', { - ...context, - traceId, - opId, - eventCount, - status: snapshot.status, - durationMs: Date.now() - startedAt, - }); - return snapshot; + let event: WorkerOperationEvent | WorkerOperationState; + try { + event = JSON.parse(payload) as WorkerOperationEvent | WorkerOperationState; + } catch { + logWorker('warn', 'sse.wait.json_parse_skipped', { + ...context, + traceId, + opId, + sample: truncateForLog(payload), + }); + continue; + } + + const snapshot: WorkerOperationState = ( + event && typeof event === 'object' && 'snapshot' in event + ? (event as WorkerOperationEvent).snapshot + : (event as WorkerOperationState) + ); + if (!snapshot || typeof snapshot !== 'object') continue; + if (snapshot.opId !== opId) continue; + + eventCount += 1; + latest = snapshot; + context.onSnapshot?.(snapshot); + if (snapshot.status !== lastStatus) { + lastStatus = snapshot.status; + logWorker('info', 'sse.wait.status', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + jobId: snapshot.jobId ?? null, + updatedAt: snapshot.updatedAt ?? null, + }); + } + if (isTerminalStatus(snapshot.status)) { + logWorker('info', 'sse.wait.terminal', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + durationMs: Date.now() - startedAt, + }); + return snapshot; + } } } + + if (latest && isTerminalStatus(latest.status)) { + logWorker('info', 'sse.wait.terminal_after_close', { + ...context, + traceId, + opId, + eventCount, + status: latest.status, + durationMs: Date.now() - startedAt, + }); + return latest; + } + + if (controller.signal.aborted) break; + await sleep(250); } if (latest && isTerminalStatus(latest.status)) { - logWorker('info', 'sse.wait.terminal_after_close', { - ...context, - traceId, - opId, - eventCount, - status: latest.status, - durationMs: Date.now() - startedAt, - }); return latest; } diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 6a40d94..1b75556 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -13,7 +13,12 @@ import { hashUserId } from '@/lib/server/documents/parse-progress-events'; 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 { PdfLayoutJobBase, PdfLayoutProgress } from '@openreader/compute-core/api-contracts'; +import type { + PdfLayoutJobBase, + PdfLayoutJobResult, + PdfLayoutProgress, + WorkerOperationState, +} from '@openreader/compute-core/api-contracts'; type UserPdfLayoutJobRequest = PdfLayoutJobBase & { userId: string; @@ -24,6 +29,7 @@ const running = new Set(); const FOLLOWER_WAIT_TIMEOUT_MS = 180_000; const FOLLOWER_POLL_MS = 1_200; +const WORKER_PROGRESS_DB_THROTTLE_MS = 10_000; type ParseRow = { userId: string; @@ -290,7 +296,72 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise }); const compute = await getCompute(); + const isWorkerCompute = compute.mode === 'worker'; + let activeOpId: string = claimOpId; + let activeJobId: string | undefined; + let lastWorkerProgressWriteAt = 0; + let lastWorkerSnapshotWriteAt = 0; + let lastWorkerSnapshotStatus: 'pending' | 'running' | null = null; + let lastWorkerSnapshotOpId: string = claimOpId; + let lastWorkerSnapshotJobId: string | undefined; + + const persistRunningState = async (state: DocumentParseState): Promise => { + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: cohortUserIds, + parseState: stringifyDocumentParseState(state), + }); + await emitParseStateEvents({ + documentId: input.documentId, + userIds: cohortUserIds, + state, + }); + }; + + const onWorkerSnapshot = async (snapshot: WorkerOperationState): Promise => { + if (!isWorkerCompute) return; + if (snapshot.opId) activeOpId = snapshot.opId; + if (snapshot.jobId) activeJobId = snapshot.jobId; + + const mappedStatus = snapshot.status === 'queued' + ? 'pending' + : snapshot.status === 'running' + ? 'running' + : null; + if (!mappedStatus) return; + + const now = Date.now(); + const forceWrite = ( + mappedStatus !== lastWorkerSnapshotStatus + || activeOpId !== lastWorkerSnapshotOpId + || activeJobId !== lastWorkerSnapshotJobId + ); + if (!forceWrite && (now - lastWorkerSnapshotWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS) { + return; + } + + const nextState: DocumentParseState = { + status: mappedStatus, + progress: snapshot.progress ?? null, + updatedAt: now, + opId: activeOpId, + ...(activeJobId ? { jobId: activeJobId } : {}), + }; + await persistRunningState(nextState); + lastWorkerSnapshotWriteAt = now; + lastWorkerSnapshotStatus = mappedStatus; + lastWorkerSnapshotOpId = activeOpId; + lastWorkerSnapshotJobId = activeJobId; + }; + const writeProgress = async (progress: PdfLayoutProgress): Promise => { + if (isWorkerCompute) { + const now = Date.now(); + if ((now - lastWorkerProgressWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS && progress.pagesParsed < progress.totalPages) { + return; + } + lastWorkerProgressWriteAt = now; + } const runningProgressState: DocumentParseState = { status: 'running', progress: { @@ -300,18 +371,10 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise phase: progress.phase, }, updatedAt: Date.now(), - opId: claimOpId, + opId: activeOpId, + ...(activeJobId ? { jobId: activeJobId } : {}), }; - await updateParseStateForUsers({ - documentId: input.documentId, - userIds: cohortUserIds, - parseState: stringifyDocumentParseState(runningProgressState), - }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: cohortUserIds, - state: runningProgressState, - }); + await persistRunningState(runningProgressState); }; const layout = await compute.parsePdfLayout({ documentId: input.documentId, @@ -319,6 +382,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise documentObjectKey: documentKey(input.documentId, input.namespace), forceToken: input.forceToken, onProgress: writeProgress, + onWorkerSnapshot, }); let parsedJsonKey = layout.parsedObjectKey ?? null; From cd06201f5c2ee30df7cbfc2b82a582edbc84bc0b Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 25 May 2026 21:37:43 -0600 Subject: [PATCH 093/137] refactor(parse-progress): rely on sse reconnect and document mode behavior --- .env.example | 4 ++++ compute/worker/.env.example | 2 ++ src/app/(app)/pdf/[id]/usePdfDocument.ts | 15 ++++----------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 34f84d4..99232c5 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ IMPORT_LIBRARY_DIRS= # Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. # local = run compute in-process (default) # worker = external durable worker mode (requires NATS JetStream-backed compute-worker service) +# Parse progress transport behavior: +# - local mode: in-process memory event bus inside app server +# - worker mode: app proxies compute-worker op events via SSE +# No app-layer NATS configuration is required for parse progress. COMPUTE_MODE=local # Required when COMPUTE_MODE=worker # COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 800dd93..ae593dd 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -39,6 +39,8 @@ S3_FORCE_PATH_STYLE=true # PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main # COMPUTE_PDF_JOB_ATTEMPTS=1 # COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 +# JetStream stream size limit for op progress events (replay for SSE reconnect) +# COMPUTE_EVENTS_STREAM_MAX_BYTES=134217728 # COMPUTE_JOB_STATES_MAX_BYTES=67108864 # COMPUTE_NATS_REPLICAS=1 # Optional stale window for reusing in-flight opKey entries before forcing a new attempt diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index fe6101d..4451333 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -216,7 +216,7 @@ export function usePdfDocument(): PdfDocumentState { } }, []); - const startParsedPolling = useCallback((documentId: string, initialStatus: PdfParseStatus | null) => { + const startParsedPolling = useCallback((documentId: string) => { parsePollAbortRef.current?.abort(); parseSseCloseRef.current?.(); parseSseCloseRef.current = null; @@ -244,15 +244,8 @@ export function usePdfDocument(): PdfDocumentState { } }, onError: () => { - // Fall back to parsed polling if browser SSE disconnects. + // EventSource reconnects automatically. Keep stream open. if (controller.signal.aborted) return; - closeSse(); - parseSseCloseRef.current = null; - void fetchParsedDocument(documentId, initialStatus, controller.signal).finally(() => { - if (parsePollAbortRef.current === controller) { - parsePollAbortRef.current = null; - } - }); }, }); parseSseCloseRef.current = closeSse; @@ -464,7 +457,7 @@ export function usePdfDocument(): PdfDocumentState { const initialParseStatus = (meta.parseStatus ?? null) as PdfParseStatus | null; setParseStatus(initialParseStatus); setParseProgress(null); - startParsedPolling(id, initialParseStatus); + startParsedPolling(id); void fetchDocumentSettings(id, controller.signal); } @@ -520,7 +513,7 @@ export function usePdfDocument(): PdfDocumentState { setParsedDocument(null); setParseStatus('pending'); setParseProgress(null); - startParsedPolling(currDocId, 'pending'); + startParsedPolling(currDocId); } catch (error) { console.error('Failed to force PDF reparse:', error); } From 00fc2d5e36370cb21c5ce0ca25ba1efd1efad767 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 11:09:43 -0600 Subject: [PATCH 094/137] feat(control-plane): add shared orchestrator and in-memory adapters --- compute/core/package.json | 1 + compute/core/src/control-plane/in-memory.ts | 139 ++++++++ compute/core/src/control-plane/index.ts | 3 + .../core/src/control-plane/orchestrator.ts | 325 ++++++++++++++++++ compute/core/src/control-plane/types.ts | 67 ++++ compute/core/src/index.ts | 1 + tests/unit/compute-control-plane.spec.ts | 153 +++++++++ 7 files changed, 689 insertions(+) create mode 100644 compute/core/src/control-plane/in-memory.ts create mode 100644 compute/core/src/control-plane/index.ts create mode 100644 compute/core/src/control-plane/orchestrator.ts create mode 100644 compute/core/src/control-plane/types.ts create mode 100644 tests/unit/compute-control-plane.spec.ts diff --git a/compute/core/package.json b/compute/core/package.json index 9f83093..4f27980 100644 --- a/compute/core/package.json +++ b/compute/core/package.json @@ -15,6 +15,7 @@ ".": "./src/index.ts", "./local-runtime": "./src/local-runtime.ts", "./api-contracts": "./src/api-contracts/index.ts", + "./control-plane": "./src/control-plane/index.ts", "./types": "./src/types/index.ts" } } diff --git a/compute/core/src/control-plane/in-memory.ts b/compute/core/src/control-plane/in-memory.ts new file mode 100644 index 0000000..fc77c0e --- /dev/null +++ b/compute/core/src/control-plane/in-memory.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from 'node:events'; +import type { + OperationEvent, + OperationEventStream, + OperationIndexEntry, + OperationQueue, + OperationState, + OperationStateStore, + QueuedOperation, +} from './types'; +import type { WorkerOperationKind } from '../api-contracts'; + +function topicFor(opId: string): string { + return `op.${opId}`; +} + +function normalizeSinceEventId(value: number | undefined): number { + if (!Number.isFinite(value ?? 0)) return 0; + return Math.max(0, Math.floor(value ?? 0)); +} + +export class InMemoryOperationQueue implements OperationQueue { + private readonly byKind = new Map(); + + constructor() { + this.byKind.set('whisper_align', []); + this.byKind.set('pdf_layout', []); + } + + async enqueue(job: QueuedOperation): Promise { + const list = this.byKind.get(job.kind); + if (!list) throw new Error(`Unsupported operation kind: ${job.kind}`); + list.push(job); + } + + async claimNext(kind: WorkerOperationKind): Promise { + const list = this.byKind.get(kind); + if (!list || list.length === 0) return null; + return list.shift() ?? null; + } + + size(kind?: WorkerOperationKind): number { + if (kind) return this.byKind.get(kind)?.length ?? 0; + let total = 0; + for (const list of this.byKind.values()) total += list.length; + return total; + } +} + +export class InMemoryOperationStateStore implements OperationStateStore { + private readonly stateByOpId = new Map(); + private readonly opIndexByKey = new Map(); + + async getOpState(opId: string): Promise { + return this.stateByOpId.get(opId) ?? null; + } + + async putOpState(state: OperationState): Promise { + this.stateByOpId.set(state.opId, state); + } + + async getOpIndex(opKey: string): Promise { + const opId = this.opIndexByKey.get(opKey); + return opId ? { opId } : null; + } + + async compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise { + const current = this.opIndexByKey.get(input.opKey) ?? null; + if (current !== input.expectedOpId) return false; + this.opIndexByKey.set(input.opKey, input.newOpId); + return true; + } +} + +export class InMemoryOperationEventStream implements OperationEventStream { + private readonly emitter = new EventEmitter(); + private readonly lastIdByOpId = new Map(); + private readonly eventsByOpId = new Map(); + + constructor() { + this.emitter.setMaxListeners(0); + } + + async append(opId: string, snapshot: OperationState): Promise { + const nextEventId = (this.lastIdByOpId.get(opId) ?? 0) + 1; + this.lastIdByOpId.set(opId, nextEventId); + + const event: OperationEvent = { + eventId: nextEventId, + snapshot, + }; + + const list = this.eventsByOpId.get(opId) ?? []; + list.push(event); + this.eventsByOpId.set(opId, list); + this.emitter.emit(topicFor(opId), event); + return event; + } + + async listSince(opId: string, sinceEventId: number, limit?: number): Promise { + const list = this.eventsByOpId.get(opId) ?? []; + const normalizedSince = normalizeSinceEventId(sinceEventId); + const filtered = list.filter((event) => event.eventId > normalizedSince); + if (!Number.isFinite(limit ?? 0) || !limit || limit <= 0) return filtered; + return filtered.slice(0, Math.floor(limit)); + } + + async subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void> { + const replay = await this.listSince(input.opId, normalizeSinceEventId(input.sinceEventId)); + for (const event of replay) { + try { + await input.onEvent(event); + } catch (error) { + input.onError?.(error); + } + } + + const listener = (event: OperationEvent): void => { + Promise.resolve(input.onEvent(event)).catch((error) => { + input.onError?.(error); + }); + }; + + const topic = topicFor(input.opId); + this.emitter.on(topic, listener); + return () => { + this.emitter.off(topic, listener); + }; + } +} diff --git a/compute/core/src/control-plane/index.ts b/compute/core/src/control-plane/index.ts new file mode 100644 index 0000000..9b67931 --- /dev/null +++ b/compute/core/src/control-plane/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './orchestrator'; +export * from './in-memory'; diff --git a/compute/core/src/control-plane/orchestrator.ts b/compute/core/src/control-plane/orchestrator.ts new file mode 100644 index 0000000..3b45372 --- /dev/null +++ b/compute/core/src/control-plane/orchestrator.ts @@ -0,0 +1,325 @@ +import { randomUUID } from 'node:crypto'; +import type { + WorkerJobErrorShape, + WorkerJobTiming, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, +} from '../api-contracts'; +import type { + OperationClock, + OperationEventStream, + OperationIdFactory, + OperationLifecycleConfig, + OperationQueue, + OperationStateStore, + QueuedOperation, +} from './types'; + +const RETRY_DELAY_MS = 25; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isTerminalStatus(status: WorkerOperationState['status']): boolean { + return status === 'succeeded' || status === 'failed'; +} + +function isInflightStatus(status: WorkerOperationState['status']): boolean { + return status === 'queued' || status === 'running'; +} + +function createErrorShape(error: unknown): WorkerJobErrorShape { + if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { + return { message: (error as { message: string }).message }; + } + return { message: String(error) }; +} + +function queuedStateFromRequest(input: { + request: WorkerOperationRequest; + opId: string; + jobId: string; + queuedAt: number; +}): WorkerOperationState { + return { + opId: input.opId, + opKey: input.request.opKey, + kind: input.request.kind, + jobId: input.jobId, + status: 'queued', + queuedAt: input.queuedAt, + updatedAt: input.queuedAt, + }; +} + +function mapReplacementReason(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + now: number; + opStaleMs: number; +}): string { + if (input.current.kind !== input.requestKind) return 'kind_mismatch'; + const ageMs = input.now - input.current.updatedAt; + if (isInflightStatus(input.current.status) && ageMs > input.opStaleMs) return 'stale_running'; + if (input.current.status === 'failed') return 'failed_prior'; + return `status_${input.current.status}`; +} + +export interface OperationOrchestratorDeps { + queue: OperationQueue; + stateStore: OperationStateStore; + eventStream: OperationEventStream; + config: OperationLifecycleConfig; + clock?: OperationClock; + idFactory?: OperationIdFactory; +} + +export class OperationOrchestrator { + private readonly queue: OperationQueue; + private readonly stateStore: OperationStateStore; + private readonly eventStream: OperationEventStream; + private readonly opStaleMs: number; + private readonly maxCasRetries: number; + private readonly clock: OperationClock; + private readonly ids: OperationIdFactory; + + constructor(deps: OperationOrchestratorDeps) { + this.queue = deps.queue; + this.stateStore = deps.stateStore; + this.eventStream = deps.eventStream; + this.opStaleMs = deps.config.opStaleMs; + this.maxCasRetries = Math.max(1, Math.floor(deps.config.maxCasRetries ?? 10)); + this.clock = deps.clock ?? { now: () => Date.now() }; + this.ids = deps.idFactory ?? { + opId: () => randomUUID(), + jobId: () => randomUUID(), + }; + } + + private async persistState(state: WorkerOperationState): Promise { + await this.stateStore.putOpState(state); + await this.eventStream.append(state.opId, state); + } + + private buildQueuedJob(state: WorkerOperationState, request: WorkerOperationRequest): QueuedOperation { + return { + jobId: state.jobId, + opId: state.opId, + opKey: state.opKey, + kind: state.kind, + queuedAt: state.queuedAt, + payload: request.payload, + }; + } + + async enqueueOrReuse(request: WorkerOperationRequest): Promise { + const opKey = request.opKey.trim(); + + for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) { + const indexEntry = await this.stateStore.getOpIndex(opKey); + if (indexEntry?.opId) { + const current = await this.stateStore.getOpState(indexEntry.opId); + if (!current) { + await sleep(RETRY_DELAY_MS); + continue; + } + + const now = this.clock.now(); + const ageMs = now - current.updatedAt; + if (current.kind === request.kind) { + if (current.status === 'succeeded') return current; + if (isInflightStatus(current.status) && ageMs <= this.opStaleMs) { + return current; + } + } + + const replacement = queuedStateFromRequest({ + request, + opId: this.ids.opId(), + jobId: this.ids.jobId(), + queuedAt: now, + }); + + const replaced = await this.stateStore.compareAndSetOpIndex({ + opKey, + newOpId: replacement.opId, + expectedOpId: indexEntry.opId, + }); + if (!replaced) { + await sleep(RETRY_DELAY_MS); + continue; + } + + await this.persistState(replacement); + + try { + await this.queue.enqueue(this.buildQueuedJob(replacement, request)); + return replacement; + } catch (error) { + const failed: WorkerOperationState = { + ...replacement, + status: 'failed', + updatedAt: this.clock.now(), + error: createErrorShape(error), + }; + await this.persistState(failed); + return failed; + } + } + + const now = this.clock.now(); + const created = queuedStateFromRequest({ + request, + opId: this.ids.opId(), + jobId: this.ids.jobId(), + queuedAt: now, + }); + + const createdIndex = await this.stateStore.compareAndSetOpIndex({ + opKey, + newOpId: created.opId, + expectedOpId: null, + }); + if (!createdIndex) { + await sleep(RETRY_DELAY_MS); + continue; + } + + await this.persistState(created); + + try { + await this.queue.enqueue(this.buildQueuedJob(created, request)); + return created; + } catch (error) { + const failed: WorkerOperationState = { + ...created, + status: 'failed', + updatedAt: this.clock.now(), + error: createErrorShape(error), + }; + await this.persistState(failed); + return failed; + } + } + + throw new Error('Unable to reserve operation after repeated CAS conflicts'); + } + + async getState(opId: string): Promise { + return this.stateStore.getOpState(opId); + } + + async markRunning(input: { + opId: string; + startedAt?: number; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'running', + startedAt: input.startedAt ?? current.startedAt ?? now, + updatedAt: now, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markProgress(input: { + opId: string; + progress: WorkerOperationState['progress']; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'running', + startedAt: current.startedAt ?? now, + updatedAt: now, + progress: input.progress, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markSucceeded(input: { + opId: string; + result: unknown; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'succeeded', + startedAt: current.startedAt ?? now, + updatedAt: now, + result: input.result, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markFailed(input: { + opId: string; + error: WorkerJobErrorShape | string; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const shape = typeof input.error === 'string' ? { message: input.error } : input.error; + + const next: WorkerOperationState = { + ...current, + status: 'failed', + startedAt: current.startedAt ?? now, + updatedAt: now, + error: shape, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async explainReuseDecision(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + }): Promise { + return mapReplacementReason({ + current: input.current, + requestKind: input.requestKind, + now: this.clock.now(), + opStaleMs: this.opStaleMs, + }); + } + + private async requireState(opId: string): Promise { + const current = await this.stateStore.getOpState(opId); + if (!current) { + throw new Error(`Operation not found: ${opId}`); + } + if (isTerminalStatus(current.status)) { + return current; + } + return current; + } +} diff --git a/compute/core/src/control-plane/types.ts b/compute/core/src/control-plane/types.ts new file mode 100644 index 0000000..01ea1fc --- /dev/null +++ b/compute/core/src/control-plane/types.ts @@ -0,0 +1,67 @@ +import type { + WorkerOperationEvent, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, +} from '../api-contracts'; + +export type OperationRequest = WorkerOperationRequest; +export type OperationState = WorkerOperationState; +export type OperationEvent = WorkerOperationEvent; + +export interface QueuedOperation { + jobId: string; + opId: string; + opKey: string; + kind: WorkerOperationKind; + queuedAt: number; + payload: TPayload; +} + +export interface OperationQueue { + enqueue(job: QueuedOperation): Promise; + claimNext(kind: WorkerOperationKind): Promise | null>; + size(kind?: WorkerOperationKind): number; +} + +export interface OperationIndexEntry { + opId: string; +} + +export interface OperationStateStore { + getOpState(opId: string): Promise | null>; + putOpState(state: OperationState): Promise; + getOpIndex(opKey: string): Promise; + compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise; +} + +export interface OperationEventStream { + append(opId: string, snapshot: OperationState): Promise>; + listSince(opId: string, sinceEventId: number, limit?: number): Promise[]>; + subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void>; +} + +export interface OperationClock { + now(): number; +} + +export interface OperationIdFactory { + opId(): string; + jobId(): string; +} + +export interface OperationLifecycleConfig { + opStaleMs: number; + maxCasRetries?: number; +} + +export type OperationTransitionStatus = 'queued' | 'running' | 'succeeded' | 'failed'; diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index c709b34..4ba1e89 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -21,3 +21,4 @@ export { normalizeTextItemsForLayout } from './pdf/normalize-text'; export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map'; export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral'; export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps'; +export * from './control-plane'; diff --git a/tests/unit/compute-control-plane.spec.ts b/tests/unit/compute-control-plane.spec.ts new file mode 100644 index 0000000..482faa2 --- /dev/null +++ b/tests/unit/compute-control-plane.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test'; +import type { WorkerOperationRequest, WorkerOperationState } from '../../compute/core/src/api-contracts'; +import { + InMemoryOperationEventStream, + InMemoryOperationQueue, + InMemoryOperationStateStore, + OperationOrchestrator, +} from '../../compute/core/src/control-plane'; + +function buildPdfLayoutRequest(opKey: string): WorkerOperationRequest { + return { + kind: 'pdf_layout', + opKey, + payload: { + documentId: `doc-${opKey}`, + documentObjectKey: `s3://bucket/${opKey}.pdf`, + namespace: null, + }, + }; +} + +test.describe('compute control-plane', () => { + test('in-memory queue and state store support enqueue/claim/CAS', async () => { + const queue = new InMemoryOperationQueue(); + const store = new InMemoryOperationStateStore(); + + await queue.enqueue({ + jobId: 'job-1', + opId: 'op-1', + opKey: 'k-1', + kind: 'pdf_layout', + queuedAt: 1000, + payload: { documentId: 'd1', documentObjectKey: 'obj1', namespace: null }, + }); + await queue.enqueue({ + jobId: 'job-2', + opId: 'op-2', + opKey: 'k-2', + kind: 'whisper_align', + queuedAt: 1100, + payload: { text: 'hello', audioObjectKey: 'obj2' }, + }); + + expect(queue.size()).toBe(2); + expect(queue.size('pdf_layout')).toBe(1); + expect(queue.size('whisper_align')).toBe(1); + + const claimedLayout = await queue.claimNext('pdf_layout'); + expect(claimedLayout?.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(0); + + const firstCas = await store.compareAndSetOpIndex({ + opKey: 'k-1', + newOpId: 'op-1', + expectedOpId: null, + }); + const secondCas = await store.compareAndSetOpIndex({ + opKey: 'k-1', + newOpId: 'op-2', + expectedOpId: null, + }); + + expect(firstCas).toBeTruthy(); + expect(secondCas).toBeFalsy(); + expect(await store.getOpIndex('k-1')).toEqual({ opId: 'op-1' }); + }); + + test('in-memory event stream replays from sinceEventId and streams live events', async () => { + const stream = new InMemoryOperationEventStream(); + + const queued: WorkerOperationState = { + opId: 'op-1', + opKey: 'k-1', + kind: 'pdf_layout', + jobId: 'job-1', + status: 'queued', + queuedAt: 1000, + updatedAt: 1000, + }; + const running: WorkerOperationState = { + ...queued, + status: 'running', + startedAt: 1200, + updatedAt: 1200, + }; + const succeeded: WorkerOperationState = { + ...running, + status: 'succeeded', + updatedAt: 1400, + result: { ok: true }, + }; + + await stream.append('op-1', queued); + await stream.append('op-1', running); + + const receivedEventIds: number[] = []; + const unsubscribe = await stream.subscribe({ + opId: 'op-1', + sinceEventId: 1, + onEvent: (event) => { + receivedEventIds.push(event.eventId); + }, + }); + + await stream.append('op-1', succeeded); + unsubscribe(); + + expect(receivedEventIds).toEqual([2, 3]); + }); + + test('orchestrator reuses fresh inflight operations and replaces stale ones', async () => { + let now = 1_000; + let nextId = 1; + const queue = new InMemoryOperationQueue(); + const stateStore = new InMemoryOperationStateStore(); + const eventStream = new InMemoryOperationEventStream(); + const orchestrator = new OperationOrchestrator({ + queue, + stateStore, + eventStream, + config: { opStaleMs: 2_000, maxCasRetries: 5 }, + clock: { now: () => now }, + idFactory: { + opId: () => `op-${nextId}`, + jobId: () => `job-${nextId++}`, + }, + }); + + const request = buildPdfLayoutRequest('same-op-key'); + + const first = await orchestrator.enqueueOrReuse(request); + expect(first.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(1); + + now = 2_000; + const reused = await orchestrator.enqueueOrReuse(request); + expect(reused.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(1); + + await orchestrator.markRunning({ opId: first.opId, updatedAt: 2_100 }); + + now = 6_000; + const replaced = await orchestrator.enqueueOrReuse(request); + expect(replaced.opId).toBe('op-2'); + expect(queue.size('pdf_layout')).toBe(2); + expect(await stateStore.getOpIndex('same-op-key')).toEqual({ opId: 'op-2' }); + + const op1Events = await eventStream.listSince('op-1', 0); + const op2Events = await eventStream.listSince('op-2', 0); + expect(op1Events.map((event) => event.eventId)).toEqual([1, 2]); + expect(op2Events.map((event) => event.eventId)).toEqual([1]); + }); +}); From 084cbdfac10899179e34bd38cbbaa0046ffc56df Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 11:16:54 -0600 Subject: [PATCH 095/137] refactor(worker-control-plane): route ops through core orchestrator --- compute/worker/src/control-plane-jetstream.ts | 229 ++++++++++++ compute/worker/src/server.ts | 349 +++--------------- ...ute-worker-control-plane-jetstream.spec.ts | 195 ++++++++++ 3 files changed, 484 insertions(+), 289 deletions(-) create mode 100644 compute/worker/src/control-plane-jetstream.ts create mode 100644 tests/unit/compute-worker-control-plane-jetstream.spec.ts diff --git a/compute/worker/src/control-plane-jetstream.ts b/compute/worker/src/control-plane-jetstream.ts new file mode 100644 index 0000000..deab1de --- /dev/null +++ b/compute/worker/src/control-plane-jetstream.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto'; +import type { JetStreamClient } from '@nats-io/jetstream'; +import type { + OperationEvent, + OperationEventStream, + OperationQueue, + OperationState, + OperationStateStore, + QueuedOperation, +} from '@openreader/compute-core/control-plane'; +import type { + PdfLayoutJobRequest, + WhisperAlignJobRequest, + WorkerOperationKind, +} from '@openreader/compute-core/api-contracts'; + +export interface KvEntryLike { + operation?: string; + value: Uint8Array; + revision: number; +} + +export interface KvStoreLike { + get(key: string): Promise; + put(key: string, data: Uint8Array): Promise; + create(key: string, data: Uint8Array): Promise; + update(key: string, data: Uint8Array, version: number): Promise; +} + +type JsonCodec = { + encode(value: T): Uint8Array; + decode(data: Uint8Array): T; +}; + +function createJsonCodec(): JsonCodec { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + return { + encode(value: T): Uint8Array { + return encoder.encode(JSON.stringify(value)); + }, + decode(data: Uint8Array): T { + return JSON.parse(decoder.decode(data)) as T; + }, + }; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function isCasConflictError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return message.includes('wrong last sequence') || message.includes('key exists') || message.includes('wrong last'); +} + +function isPut(entry: KvEntryLike | null): entry is KvEntryLike { + return Boolean(entry && entry.operation === 'PUT'); +} + +interface OpIndexEntry { + opId: string; +} + +const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; + +export function hashOpKey(opKey: string): string { + return createHash('sha256').update(opKey).digest('hex'); +} + +export function opIndexKvKey(opKey: string): string { + return `op_index.${hashOpKey(opKey)}`; +} + +export function opStateKvKey(opId: string): string { + return `op_state.${opId}`; +} + +export function opEventsSubject(opId: string): string { + return `${OP_EVENTS_SUBJECT_PREFIX}.${opId}`; +} + +export interface JetStreamOperationStateStoreDeps { + getKv: () => Promise; +} + +export class JetStreamOperationStateStore implements OperationStateStore { + private readonly getKv: () => Promise; + private readonly opStateCodec = createJsonCodec>(); + private readonly opIndexCodec = createJsonCodec(); + + constructor(deps: JetStreamOperationStateStoreDeps) { + this.getKv = deps.getKv; + } + + async getOpState(opId: string): Promise | null> { + const kv = await this.getKv(); + const entry = await kv.get(opStateKvKey(opId)); + if (!isPut(entry)) return null; + return this.opStateCodec.decode(entry.value); + } + + async putOpState(state: OperationState): Promise { + const kv = await this.getKv(); + await kv.put(opStateKvKey(state.opId), this.opStateCodec.encode(state)); + } + + async getOpIndex(opKey: string): Promise<{ opId: string } | null> { + const kv = await this.getKv(); + const entry = await kv.get(opIndexKvKey(opKey)); + if (!isPut(entry)) return null; + return this.opIndexCodec.decode(entry.value); + } + + async compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise { + const kv = await this.getKv(); + const key = opIndexKvKey(input.opKey); + const value = this.opIndexCodec.encode({ opId: input.newOpId }); + + if (input.expectedOpId === null) { + try { + await kv.create(key, value); + return true; + } catch (error) { + if (isCasConflictError(error)) return false; + throw error; + } + } + + const current = await kv.get(key); + if (!isPut(current)) return false; + const decoded = this.opIndexCodec.decode(current.value); + if (decoded.opId !== input.expectedOpId) return false; + + try { + await kv.update(key, value, current.revision); + return true; + } catch (error) { + if (isCasConflictError(error)) return false; + throw error; + } + } +} + +export interface JetStreamOperationEventStreamDeps { + getJs: () => Promise>; +} + +export class JetStreamOperationEventStream implements OperationEventStream { + private readonly getJs: () => Promise>; + + constructor(deps: JetStreamOperationEventStreamDeps) { + this.getJs = deps.getJs; + } + + async append(opId: string, snapshot: OperationState): Promise> { + const js = await this.getJs(); + const encoder = new TextEncoder(); + const ack = await js.publish(opEventsSubject(opId), encoder.encode(JSON.stringify(snapshot))); + return { + eventId: ack.seq, + snapshot, + }; + } + + async listSince(): Promise[]> { + return []; + } + + async subscribe(): Promise<() => void> { + return () => undefined; + } +} + +export interface JetStreamOperationQueueDeps { + getJs: () => Promise>; + whisperSubject: string; + layoutSubject: string; + onEnqueued?: (job: QueuedOperation) => Promise | void; +} + +export class JetStreamOperationQueue implements OperationQueue { + private readonly getJs: () => Promise>; + private readonly whisperSubject: string; + private readonly layoutSubject: string; + private readonly onEnqueued?: (job: QueuedOperation) => Promise | void; + private readonly whisperCodec = createJsonCodec>(); + private readonly layoutCodec = createJsonCodec>(); + + constructor(deps: JetStreamOperationQueueDeps) { + this.getJs = deps.getJs; + this.whisperSubject = deps.whisperSubject; + this.layoutSubject = deps.layoutSubject; + this.onEnqueued = deps.onEnqueued; + } + + async enqueue(job: QueuedOperation): Promise { + const js = await this.getJs(); + if (job.kind === 'whisper_align') { + await js.publish( + this.whisperSubject, + this.whisperCodec.encode(job as QueuedOperation), + ); + } else if (job.kind === 'pdf_layout') { + await js.publish( + this.layoutSubject, + this.layoutCodec.encode(job as QueuedOperation), + ); + } else { + const exhaustive: never = job.kind; + throw new Error(`Unsupported operation kind: ${String(exhaustive)}`); + } + + await this.onEnqueued?.(job); + } + + async claimNext(_kind: WorkerOperationKind): Promise | null> { + throw new Error('JetStreamOperationQueue.claimNext is not used by the worker runtime'); + } + + size(): number { + return 0; + } +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index ea30c8b..fafa45b 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import Fastify, { type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { @@ -34,6 +33,7 @@ import { withIdleTimeoutAndHardCap, withTimeout, } from '@openreader/compute-core'; +import { OperationOrchestrator } from '@openreader/compute-core/control-plane'; import type { PdfLayoutJobRequest, PdfLayoutJobResult, @@ -49,6 +49,13 @@ import type { PdfLayoutProgress, } from '@openreader/compute-core/api-contracts'; import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + JetStreamOperationEventStream, + JetStreamOperationQueue, + JetStreamOperationStateStore, + hashOpKey, + opEventsSubject, +} from './control-plane-jetstream'; const JOBS_STREAM_NAME = 'compute_jobs'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; @@ -104,10 +111,6 @@ interface StoredJobState { progress?: PdfLayoutProgress; } -interface OpIndexEntry { - opId: string; -} - interface NatsSession { nc: NatsConnection; js: JetStreamClient; @@ -295,11 +298,6 @@ function isAlreadyExistsError(error: unknown): boolean { return message.includes('already in use') || message.includes('already exists'); } -function isCasConflictError(error: unknown): boolean { - const message = toErrorMessage(error).toLowerCase(); - return message.includes('wrong last sequence') || message.includes('key exists') || message.includes('wrong last'); -} - function createJsonCodec(): JsonCodec { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -351,26 +349,10 @@ function isTerminalStatus(status: WorkerJobState): boolean { return status === 'succeeded' || status === 'failed'; } -function hashOpKey(opKey: string): string { - return createHash('sha256').update(opKey).digest('hex'); -} - -function opIndexKvKey(opKey: string): string { - return `op_index.${hashOpKey(opKey)}`; -} - -function opStateKvKey(opId: string): string { - return `op_state.${opId}`; -} - function jobStateKvKey(jobId: string): string { return `job_state.${jobId}`; } -function opEventsSubject(opId: string): string { - return `${OP_EVENTS_SUBJECT_PREFIX}.${opId}`; -} - function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; const maybe = result as { parsedObjectKey?: unknown }; @@ -682,18 +664,55 @@ async function main(): Promise { pdfLayoutHardCapMs: pdfHardCapMs, }, 'compute runtime config'); - const opIndexCodec = createJsonCodec(); - const opStateCodec = createJsonCodec(); const whisperJobCodec = createJsonCodec>(); const layoutJobCodec = createJsonCodec>(); const jobStateCodec = createJsonCodec>(); const opEventCodec = createJsonCodec(); + const putJobState = async (state: StoredJobState): Promise => { + const { kv } = await ensureConnected(); + await kv.put(jobStateKvKey(state.jobId), jobStateCodec.encode(state)); + }; + + const operationStateStore = new JetStreamOperationStateStore({ + getKv: async () => (await ensureConnected()).kv, + }); + + const operationEventStream = new JetStreamOperationEventStream({ + getJs: async () => (await ensureConnected()).js, + }); + + const operationQueue = new JetStreamOperationQueue({ + getJs: async () => (await ensureConnected()).js, + whisperSubject: WHISPER_JOBS_SUBJECT, + layoutSubject: LAYOUT_JOBS_SUBJECT, + onEnqueued: async (job) => { + await putJobState({ + jobId: job.jobId, + opId: job.opId, + opKey: job.opKey, + kind: job.kind, + status: 'queued', + timestamp: job.queuedAt, + updatedAt: job.queuedAt, + }); + }, + }); + + const orchestrator = new OperationOrchestrator({ + queue: operationQueue, + stateStore: operationStateStore, + eventStream: operationEventStream, + config: { + opStaleMs, + maxCasRetries: 10, + }, + }); + const putOpState = async (state: StreamedOperationState): Promise => { - const { kv, js } = await ensureConnected(); - await kv.put(opStateKvKey(state.opId), opStateCodec.encode(state)); + await operationStateStore.putOpState(state); try { - await js.publish(opEventsSubject(state.opId), opEventCodec.encode(state)); + await operationEventStream.append(state.opId, state); } catch (error) { app.log.warn({ opId: state.opId, @@ -704,263 +723,7 @@ async function main(): Promise { }; const getOpState = async (opId: string): Promise => { - const { kv } = await ensureConnected(); - const entry = await kv.get(opStateKvKey(opId)); - if (!entry || entry.operation !== 'PUT') return null; - return opStateCodec.decode(entry.value); - }; - - const putJobState = async (state: StoredJobState): Promise => { - const { kv } = await ensureConnected(); - await kv.put(jobStateKvKey(state.jobId), jobStateCodec.encode(state)); - }; - - const publishQueuedJob = async ( - op: WorkerOperationState, - payload: WhisperAlignJobRequest | PdfLayoutJobRequest, - ): Promise => { - const { js } = await ensureConnected(); - if (op.kind === 'whisper_align') { - await js.publish(WHISPER_JOBS_SUBJECT, whisperJobCodec.encode({ - jobId: op.jobId, - opId: op.opId, - opKey: op.opKey, - kind: 'whisper_align', - queuedAt: op.queuedAt, - payload: payload as WhisperAlignJobRequest, - })); - return; - } - - await js.publish(LAYOUT_JOBS_SUBJECT, layoutJobCodec.encode({ - jobId: op.jobId, - opId: op.opId, - opKey: op.opKey, - kind: 'pdf_layout', - queuedAt: op.queuedAt, - payload: payload as PdfLayoutJobRequest, - })); - }; - - const enqueueOrReuseOperation = async ( - req: WorkerOperationRequest, - ): Promise> => { - const opKey = req.opKey.trim(); - const indexKey = opIndexKvKey(opKey); - const opKeyHash = hashOpKey(opKey).slice(0, 16); - const { kv } = await ensureConnected(); - - for (let attemptNo = 0; attemptNo < 10; attemptNo += 1) { - const indexEntry = await kv.get(indexKey); - if (indexEntry && indexEntry.operation === 'PUT') { - const pointer = opIndexCodec.decode(indexEntry.value); - const current = await getOpState(pointer.opId); - - if (!current) { - await sleep(25); - continue; - } - - if (current && current.kind === req.kind) { - const ageMs = Date.now() - current.updatedAt; - if (current.status === 'succeeded') { - app.log.info({ - kind: req.kind, - opId: current.opId, - jobId: current.jobId, - opKeyHash, - action: 'reused_terminal', - status: current.status, - ageMs, - }, 'op.accepted'); - return current; - } - if ((current.status === 'queued' || current.status === 'running') && ageMs <= opStaleMs) { - app.log.info({ - kind: req.kind, - opId: current.opId, - jobId: current.jobId, - opKeyHash, - action: 'reused_inflight', - status: current.status, - ageMs, - }, 'op.accepted'); - return current; - } - if ((current.status === 'queued' || current.status === 'running') && ageMs > opStaleMs) { - app.log.warn({ - kind: req.kind, - opId: current.opId, - jobId: current.jobId, - opKeyHash, - status: current.status, - ageMs, - opStaleMs, - }, 'op.stuck_detected'); - } - } - - const replacementReason = (() => { - if (current.kind !== req.kind) return 'kind_mismatch'; - const ageMs = Date.now() - current.updatedAt; - if ((current.status === 'queued' || current.status === 'running') && ageMs > opStaleMs) return 'stale_running'; - if (current.status === 'failed') return 'failed_prior'; - return `status_${current.status}`; - })(); - - const now = Date.now(); - const replacement: WorkerOperationState = { - opId: crypto.randomUUID(), - opKey, - kind: req.kind, - jobId: crypto.randomUUID(), - status: 'queued', - queuedAt: now, - updatedAt: now, - }; - - try { - await kv.update(indexKey, opIndexCodec.encode({ opId: replacement.opId }), indexEntry.revision); - } catch (error) { - if (isCasConflictError(error)) continue; - throw error; - } - - await putOpState(replacement); - await putJobState({ - jobId: replacement.jobId, - opId: replacement.opId, - opKey: replacement.opKey, - kind: replacement.kind, - status: 'queued', - timestamp: now, - updatedAt: now, - }); - - try { - await publishQueuedJob(replacement, req.payload); - app.log.info({ - kind: req.kind, - opId: replacement.opId, - jobId: replacement.jobId, - opKeyHash, - reason: replacementReason, - status: replacement.status, - }, 'op.replaced'); - app.log.info({ - kind: req.kind, - opId: replacement.opId, - jobId: replacement.jobId, - opKeyHash, - action: 'replaced', - status: replacement.status, - reason: replacementReason, - }, 'op.accepted'); - return replacement; - } catch (error) { - const failed: WorkerOperationState = { - ...replacement, - status: 'failed', - updatedAt: Date.now(), - error: { message: toErrorMessage(error) }, - }; - await putOpState(failed); - await putJobState({ - jobId: replacement.jobId, - opId: replacement.opId, - opKey: replacement.opKey, - kind: replacement.kind, - status: 'failed', - timestamp: replacement.queuedAt, - updatedAt: failed.updatedAt, - error: failed.error, - }); - app.log.error({ - kind: req.kind, - opId: failed.opId, - jobId: failed.jobId, - opKeyHash, - action: 'replaced', - status: failed.status, - reason: replacementReason, - error: failed.error?.message ?? null, - }, 'op.accepted'); - return failed; - } - } - - const now = Date.now(); - const created: WorkerOperationState = { - opId: crypto.randomUUID(), - opKey, - kind: req.kind, - jobId: crypto.randomUUID(), - status: 'queued', - queuedAt: now, - updatedAt: now, - }; - - try { - await kv.create(indexKey, opIndexCodec.encode({ opId: created.opId })); - } catch (error) { - if (isCasConflictError(error)) continue; - throw error; - } - - await putOpState(created); - await putJobState({ - jobId: created.jobId, - opId: created.opId, - opKey: created.opKey, - kind: created.kind, - status: 'queued', - timestamp: now, - updatedAt: now, - }); - - try { - await publishQueuedJob(created, req.payload); - app.log.info({ - kind: req.kind, - opId: created.opId, - jobId: created.jobId, - opKeyHash, - action: 'created', - status: created.status, - }, 'op.accepted'); - return created; - } catch (error) { - const failed: WorkerOperationState = { - ...created, - status: 'failed', - updatedAt: Date.now(), - error: { message: toErrorMessage(error) }, - }; - await putOpState(failed); - await putJobState({ - jobId: created.jobId, - opId: created.opId, - opKey: created.opKey, - kind: created.kind, - status: 'failed', - timestamp: created.queuedAt, - updatedAt: failed.updatedAt, - error: failed.error, - }); - app.log.error({ - kind: req.kind, - opId: failed.opId, - jobId: failed.jobId, - opKeyHash, - action: 'created', - status: failed.status, - error: failed.error?.message ?? null, - }, 'op.accepted'); - return failed; - } - } - - throw new Error('Unable to reserve operation after repeated CAS conflicts'); + return await operationStateStore.getOpState(opId); }; const releaseHttp = (request: FastifyRequest): void => { @@ -1024,7 +787,15 @@ async function main(): Promise { }; } - const op = await enqueueOrReuseOperation(parsed.data as WorkerOperationRequest); + const requestOp = parsed.data as WorkerOperationRequest; + const op = await orchestrator.enqueueOrReuse(requestOp); + app.log.info({ + kind: requestOp.kind, + opId: op.opId, + jobId: op.jobId, + status: op.status, + opKeyHash: hashOpKey(requestOp.opKey.trim()).slice(0, 16), + }, 'op.accepted'); reply.code(202); return op; }); diff --git a/tests/unit/compute-worker-control-plane-jetstream.spec.ts b/tests/unit/compute-worker-control-plane-jetstream.spec.ts new file mode 100644 index 0000000..699534c --- /dev/null +++ b/tests/unit/compute-worker-control-plane-jetstream.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from '@playwright/test'; +import { OperationOrchestrator } from '../../compute/core/src/control-plane'; +import type { WorkerOperationRequest } from '../../compute/core/src/api-contracts'; +import { + JetStreamOperationEventStream, + JetStreamOperationQueue, + JetStreamOperationStateStore, + opEventsSubject, + opIndexKvKey, + opStateKvKey, + type KvEntryLike, + type KvStoreLike, +} from '../../compute/worker/src/control-plane-jetstream'; + +class FakeKvStore implements KvStoreLike { + private readonly data = new Map(); + private revision = 0; + + async get(key: string): Promise { + const value = this.data.get(key); + if (!value) return null; + return { + operation: value.operation, + value: value.value.slice(), + revision: value.revision, + }; + } + + async put(key: string, data: Uint8Array): Promise { + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } + + async create(key: string, data: Uint8Array): Promise { + if (this.data.has(key)) { + throw new Error('key exists'); + } + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } + + async update(key: string, data: Uint8Array, version: number): Promise { + const current = this.data.get(key); + if (!current || current.revision !== version) { + throw new Error('wrong last sequence'); + } + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } +} + +class FakeJetStream { + private seq = 0; + readonly published: Array<{ subject: string; payload: unknown; seq: number }> = []; + + async publish(subject: string, data: Uint8Array): Promise<{ seq: number }> { + this.seq += 1; + const payload = JSON.parse(new TextDecoder().decode(data)) as unknown; + this.published.push({ subject, payload, seq: this.seq }); + return { seq: this.seq }; + } +} + +function buildPdfRequest(opKey: string): WorkerOperationRequest { + return { + kind: 'pdf_layout', + opKey, + payload: { + documentId: 'd1', + namespace: null, + documentObjectKey: 's3://bucket/doc.pdf', + }, + }; +} + +test.describe('worker jetstream control-plane adapters', () => { + test('state store compareAndSet handles create/update semantics', async () => { + const kv = new FakeKvStore(); + const store = new JetStreamOperationStateStore({ getKv: async () => kv }); + + const created = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-1', + expectedOpId: null, + }); + const failedCreate = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: null, + }); + const wrongExpected = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: 'op-x', + }); + const updated = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: 'op-1', + }); + + expect(created).toBeTruthy(); + expect(failedCreate).toBeFalsy(); + expect(wrongExpected).toBeFalsy(); + expect(updated).toBeTruthy(); + expect(await store.getOpIndex('k1')).toEqual({ opId: 'op-2' }); + }); + + test('queue and event adapters publish expected JetStream subjects', async () => { + const js = new FakeJetStream(); + const queue = new JetStreamOperationQueue({ + getJs: async () => js, + whisperSubject: 'jobs.whisper', + layoutSubject: 'jobs.layout', + }); + const events = new JetStreamOperationEventStream({ + getJs: async () => js, + }); + + await queue.enqueue({ + jobId: 'j1', + opId: 'o1', + opKey: 'k1', + kind: 'pdf_layout', + queuedAt: 1000, + payload: { documentId: 'd1', namespace: null, documentObjectKey: 'obj' }, + }); + + const appended = await events.append('o1', { + opId: 'o1', + opKey: 'k1', + kind: 'pdf_layout', + jobId: 'j1', + status: 'queued', + queuedAt: 1000, + updatedAt: 1000, + }); + + expect(js.published.map((entry) => entry.subject)).toEqual(['jobs.layout', opEventsSubject('o1')]); + expect(appended.eventId).toBe(2); + }); + + test('orchestrator integration writes index/state and reuses active op', async () => { + const kv = new FakeKvStore(); + const js = new FakeJetStream(); + + const store = new JetStreamOperationStateStore({ getKv: async () => kv }); + const events = new JetStreamOperationEventStream({ getJs: async () => js }); + const queue = new JetStreamOperationQueue({ + getJs: async () => js, + whisperSubject: 'jobs.whisper', + layoutSubject: 'jobs.layout', + }); + + let now = 1_000; + let nextId = 1; + const orchestrator = new OperationOrchestrator({ + queue, + stateStore: store, + eventStream: events, + config: { opStaleMs: 10_000, maxCasRetries: 3 }, + clock: { now: () => now }, + idFactory: { + opId: () => `op-${nextId}`, + jobId: () => `job-${nextId++}`, + }, + }); + + const req = buildPdfRequest('k-integration'); + const first = await orchestrator.enqueueOrReuse(req); + now = 2_000; + const reused = await orchestrator.enqueueOrReuse(req); + + expect(first.opId).toBe('op-1'); + expect(reused.opId).toBe('op-1'); + + const indexEntry = await kv.get(opIndexKvKey('k-integration')); + const stateEntry = await kv.get(opStateKvKey('op-1')); + expect(indexEntry?.operation).toBe('PUT'); + expect(stateEntry?.operation).toBe('PUT'); + expect(js.published.map((entry) => entry.subject)).toEqual(['ops.events.op-1', 'jobs.layout']); + }); +}); From df05a7d7a34b16aa10b11d68025044cc1147b425 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 11:41:33 -0600 Subject: [PATCH 096/137] refactor(control-plane): hard-cut worker events and shared SSE helpers --- compute/core/src/control-plane/index.ts | 2 + .../core/src/control-plane/orchestrator.ts | 71 ++---- compute/core/src/control-plane/sse.ts | 49 ++++ .../core/src/control-plane/state-machine.ts | 65 ++++++ .../jetstream.ts} | 116 +++++++++- compute/worker/src/server.ts | 213 ++++++++---------- .../api/documents/[id]/parsed/events/route.ts | 27 +-- src/lib/server/compute/worker.ts | 29 +-- tests/unit/compute-control-plane.spec.ts | 48 ++++ ...ute-worker-control-plane-jetstream.spec.ts | 25 +- 10 files changed, 417 insertions(+), 228 deletions(-) create mode 100644 compute/core/src/control-plane/sse.ts create mode 100644 compute/core/src/control-plane/state-machine.ts rename compute/worker/src/{control-plane-jetstream.ts => control-plane/jetstream.ts} (63%) diff --git a/compute/core/src/control-plane/index.ts b/compute/core/src/control-plane/index.ts index 9b67931..8e6b18c 100644 --- a/compute/core/src/control-plane/index.ts +++ b/compute/core/src/control-plane/index.ts @@ -1,3 +1,5 @@ export * from './types'; +export * from './state-machine'; export * from './orchestrator'; export * from './in-memory'; +export * from './sse'; diff --git a/compute/core/src/control-plane/orchestrator.ts b/compute/core/src/control-plane/orchestrator.ts index 3b45372..950fe71 100644 --- a/compute/core/src/control-plane/orchestrator.ts +++ b/compute/core/src/control-plane/orchestrator.ts @@ -6,6 +6,13 @@ import type { WorkerOperationRequest, WorkerOperationState, } from '../api-contracts'; +import { + buildQueuedState, + createErrorShape, + explainReplacementReason, + isTerminalStatus, + shouldReuseExistingOperation, +} from './state-machine'; import type { OperationClock, OperationEventStream, @@ -22,51 +29,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isTerminalStatus(status: WorkerOperationState['status']): boolean { - return status === 'succeeded' || status === 'failed'; -} - -function isInflightStatus(status: WorkerOperationState['status']): boolean { - return status === 'queued' || status === 'running'; -} - -function createErrorShape(error: unknown): WorkerJobErrorShape { - if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { - return { message: (error as { message: string }).message }; - } - return { message: String(error) }; -} - -function queuedStateFromRequest(input: { - request: WorkerOperationRequest; - opId: string; - jobId: string; - queuedAt: number; -}): WorkerOperationState { - return { - opId: input.opId, - opKey: input.request.opKey, - kind: input.request.kind, - jobId: input.jobId, - status: 'queued', - queuedAt: input.queuedAt, - updatedAt: input.queuedAt, - }; -} - -function mapReplacementReason(input: { - current: WorkerOperationState; - requestKind: WorkerOperationKind; - now: number; - opStaleMs: number; -}): string { - if (input.current.kind !== input.requestKind) return 'kind_mismatch'; - const ageMs = input.now - input.current.updatedAt; - if (isInflightStatus(input.current.status) && ageMs > input.opStaleMs) return 'stale_running'; - if (input.current.status === 'failed') return 'failed_prior'; - return `status_${input.current.status}`; -} - export interface OperationOrchestratorDeps { queue: OperationQueue; stateStore: OperationStateStore; @@ -127,15 +89,16 @@ export class OperationOrchestrator { } const now = this.clock.now(); - const ageMs = now - current.updatedAt; - if (current.kind === request.kind) { - if (current.status === 'succeeded') return current; - if (isInflightStatus(current.status) && ageMs <= this.opStaleMs) { - return current; - } + if (shouldReuseExistingOperation({ + current, + requestKind: request.kind, + now, + opStaleMs: this.opStaleMs, + })) { + return current; } - const replacement = queuedStateFromRequest({ + const replacement = buildQueuedState({ request, opId: this.ids.opId(), jobId: this.ids.jobId(), @@ -170,7 +133,7 @@ export class OperationOrchestrator { } const now = this.clock.now(); - const created = queuedStateFromRequest({ + const created = buildQueuedState({ request, opId: this.ids.opId(), jobId: this.ids.jobId(), @@ -304,7 +267,7 @@ export class OperationOrchestrator { current: WorkerOperationState; requestKind: WorkerOperationKind; }): Promise { - return mapReplacementReason({ + return explainReplacementReason({ current: input.current, requestKind: input.requestKind, now: this.clock.now(), diff --git a/compute/core/src/control-plane/sse.ts b/compute/core/src/control-plane/sse.ts new file mode 100644 index 0000000..712f6af --- /dev/null +++ b/compute/core/src/control-plane/sse.ts @@ -0,0 +1,49 @@ +export interface SseFrameInput { + event?: string; + id?: string | number; + data?: T; + comment?: string; +} + +export function encodeSseFrame(input: SseFrameInput): string { + const lines: string[] = []; + if (typeof input.comment === 'string') { + lines.push(`: ${input.comment}`); + } + if (typeof input.id !== 'undefined') { + lines.push(`id: ${String(input.id)}`); + } + if (typeof input.event === 'string' && input.event.trim()) { + lines.push(`event: ${input.event}`); + } + if (typeof input.data !== 'undefined') { + const serialized = typeof input.data === 'string' ? input.data : JSON.stringify(input.data); + for (const line of serialized.replace(/\r\n/g, '\n').split('\n')) { + lines.push(`data: ${line}`); + } + } + return `${lines.join('\n')}\n\n`; +} + +export function parseSsePayload(frame: string): string | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + const dataLines: string[] = []; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + dataLines.push(line.slice('data:'.length).trimStart()); + } + return dataLines.length > 0 ? dataLines.join('\n') : null; +} + +export function parseSseEventId(frame: string): number | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + for (const line of lines) { + if (!line.startsWith('id:')) continue; + const value = Number(line.slice('id:'.length).trim()); + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + } + return null; +} + diff --git a/compute/core/src/control-plane/state-machine.ts b/compute/core/src/control-plane/state-machine.ts new file mode 100644 index 0000000..d280215 --- /dev/null +++ b/compute/core/src/control-plane/state-machine.ts @@ -0,0 +1,65 @@ +import type { + WorkerJobErrorShape, + WorkerJobState, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, +} from '../api-contracts'; + +export function isTerminalStatus(status: WorkerJobState): boolean { + return status === 'succeeded' || status === 'failed'; +} + +export function isInflightStatus(status: WorkerJobState): boolean { + return status === 'queued' || status === 'running'; +} + +export function createErrorShape(error: unknown): WorkerJobErrorShape { + if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { + return { message: (error as { message: string }).message }; + } + return { message: String(error) }; +} + +export function buildQueuedState(input: { + request: WorkerOperationRequest; + opId: string; + jobId: string; + queuedAt: number; +}): WorkerOperationState { + return { + opId: input.opId, + opKey: input.request.opKey, + kind: input.request.kind, + jobId: input.jobId, + status: 'queued', + queuedAt: input.queuedAt, + updatedAt: input.queuedAt, + }; +} + +export function explainReplacementReason(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + now: number; + opStaleMs: number; +}): string { + if (input.current.kind !== input.requestKind) return 'kind_mismatch'; + const ageMs = input.now - input.current.updatedAt; + if (isInflightStatus(input.current.status) && ageMs > input.opStaleMs) return 'stale_running'; + if (input.current.status === 'failed') return 'failed_prior'; + return `status_${input.current.status}`; +} + +export function shouldReuseExistingOperation(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + now: number; + opStaleMs: number; +}): boolean { + if (input.current.kind !== input.requestKind) return false; + if (input.current.status === 'succeeded') return true; + if (!isInflightStatus(input.current.status)) return false; + const ageMs = input.now - input.current.updatedAt; + return ageMs <= input.opStaleMs; +} diff --git a/compute/worker/src/control-plane-jetstream.ts b/compute/worker/src/control-plane/jetstream.ts similarity index 63% rename from compute/worker/src/control-plane-jetstream.ts rename to compute/worker/src/control-plane/jetstream.ts index deab1de..52d183f 100644 --- a/compute/worker/src/control-plane-jetstream.ts +++ b/compute/worker/src/control-plane/jetstream.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; -import type { JetStreamClient } from '@nats-io/jetstream'; +import { AckPolicy, DeliverPolicy, ReplayPolicy, type JetStreamClient, type JetStreamManager } from '@nats-io/jetstream'; +import { nanos } from '@nats-io/transport-node'; import type { OperationEvent, OperationEventStream, @@ -148,14 +149,24 @@ export class JetStreamOperationStateStore implements Operation } export interface JetStreamOperationEventStreamDeps { - getJs: () => Promise>; + getJs: () => Promise>; + getJsm: () => Promise>; + eventsStreamName: string; + inactiveThresholdMs?: number; } export class JetStreamOperationEventStream implements OperationEventStream { - private readonly getJs: () => Promise>; + private readonly getJs: () => Promise>; + private readonly getJsm: () => Promise>; + private readonly eventsStreamName: string; + private readonly inactiveThresholdNanos: number; + private readonly opStateCodec = createJsonCodec>(); constructor(deps: JetStreamOperationEventStreamDeps) { this.getJs = deps.getJs; + this.getJsm = deps.getJsm; + this.eventsStreamName = deps.eventsStreamName; + this.inactiveThresholdNanos = nanos((deps.inactiveThresholdMs ?? 60_000)); } async append(opId: string, snapshot: OperationState): Promise> { @@ -168,12 +179,103 @@ export class JetStreamOperationEventStream implements Operatio }; } - async listSince(): Promise[]> { - return []; + private async createConsumer(input: { + opId: string; + sinceEventId?: number; + replayOnly: boolean; + }): Promise<{ name: string; js: Pick }> { + const js = await this.getJs(); + const jsm = await this.getJsm(); + const subject = opEventsSubject(input.opId); + const since = Math.max(0, Math.floor(input.sinceEventId ?? 0)); + const name = `op_events_${input.opId.slice(0, 12)}_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`; + const config = { + name, + ack_policy: AckPolicy.None, + deliver_policy: since > 0 ? DeliverPolicy.StartSequence : (input.replayOnly ? DeliverPolicy.All : DeliverPolicy.New), + replay_policy: ReplayPolicy.Instant, + filter_subject: subject, + max_deliver: 1, + inactive_threshold: this.inactiveThresholdNanos, + ...(since > 0 ? { opt_start_seq: since + 1 } : {}), + }; + await jsm.consumers.add(this.eventsStreamName, config); + return { name, js }; } - async subscribe(): Promise<() => void> { - return () => undefined; + private async deleteConsumer(name: string): Promise { + const jsm = await this.getJsm(); + await jsm.consumers.delete(this.eventsStreamName, name).catch(() => undefined); + } + + async listSince(opId: string, sinceEventId: number, limit = 200): Promise[]> { + const boundedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 200; + const { name, js } = await this.createConsumer({ + opId, + sinceEventId, + replayOnly: true, + }); + try { + const consumer = await js.consumers.get(this.eventsStreamName, name); + const output: OperationEvent[] = []; + while (output.length < boundedLimit) { + const msg = await consumer.next({ expires: 250 }); + if (!msg) break; + output.push({ + eventId: msg.seq, + snapshot: this.opStateCodec.decode(msg.data), + }); + } + return output; + } finally { + await this.deleteConsumer(name); + } + } + + async subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void> { + const { name, js } = await this.createConsumer({ + opId: input.opId, + sinceEventId: input.sinceEventId, + replayOnly: false, + }); + const consumer = await js.consumers.get(this.eventsStreamName, name); + const messages = await consumer.consume(); + let closed = false; + + void (async () => { + try { + for await (const msg of messages) { + if (closed) break; + try { + await input.onEvent({ + eventId: msg.seq, + snapshot: this.opStateCodec.decode(msg.data), + }); + } catch (error) { + input.onError?.(error); + } + } + } catch (error) { + if (!closed) input.onError?.(error); + } finally { + if (!closed) { + closed = true; + await this.deleteConsumer(name); + } + } + })(); + + return () => { + if (closed) return; + closed = true; + void messages.close().catch(() => undefined); + void this.deleteConsumer(name); + }; } } diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index fafa45b..3df73f5 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -33,7 +33,7 @@ import { withIdleTimeoutAndHardCap, withTimeout, } from '@openreader/compute-core'; -import { OperationOrchestrator } from '@openreader/compute-core/control-plane'; +import { encodeSseFrame, OperationOrchestrator } from '@openreader/compute-core/control-plane'; import type { PdfLayoutJobRequest, PdfLayoutJobResult, @@ -54,8 +54,7 @@ import { JetStreamOperationQueue, JetStreamOperationStateStore, hashOpKey, - opEventsSubject, -} from './control-plane-jetstream'; +} from './control-plane/jetstream'; const JOBS_STREAM_NAME = 'compute_jobs'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; @@ -667,7 +666,6 @@ async function main(): Promise { const whisperJobCodec = createJsonCodec>(); const layoutJobCodec = createJsonCodec>(); const jobStateCodec = createJsonCodec>(); - const opEventCodec = createJsonCodec(); const putJobState = async (state: StoredJobState): Promise => { const { kv } = await ensureConnected(); @@ -680,6 +678,8 @@ async function main(): Promise { const operationEventStream = new JetStreamOperationEventStream({ getJs: async () => (await ensureConnected()).js, + getJsm: async () => (await ensureConnected()).jsm, + eventsStreamName: EVENTS_STREAM_NAME, }); const operationQueue = new JetStreamOperationQueue({ @@ -709,19 +709,6 @@ async function main(): Promise { }, }); - const putOpState = async (state: StreamedOperationState): Promise => { - await operationStateStore.putOpState(state); - try { - await operationEventStream.append(state.opId, state); - } catch (error) { - app.log.warn({ - opId: state.opId, - status: state.status, - error: toErrorMessage(error), - }, 'failed to publish op event'); - } - }; - const getOpState = async (opId: string): Promise => { return await operationStateStore.getOpState(opId); }; @@ -852,22 +839,38 @@ async function main(): Promise { reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); + let closed = false; + let unsubscribe: (() => void) | null = null; + const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => { if (closed || reply.raw.writableEnded) return; const frameEvent: WorkerOperationEvent = { eventId, snapshot, }; - reply.raw.write(`id: ${eventId}\nevent: snapshot\ndata: ${JSON.stringify(frameEvent)}\n\n`); + reply.raw.write(encodeSseFrame({ + id: eventId, + event: 'snapshot', + data: frameEvent, + })); }; - let closed = false; - let consumerName: string | null = null; - let messages: Awaited> | null = null; - request.raw.on('close', () => { + const closeStream = (): void => { + if (closed) return; closed = true; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } activeSse = Math.max(0, activeSse - 1); markActivity(); + if (!reply.raw.writableEnded) { + reply.raw.end(); + } + }; + + request.raw.on('close', () => { + closeStream(); }); try { @@ -878,52 +881,41 @@ async function main(): Promise { return reply; } - const { jsm, js } = await ensureConnected(); - const consumerConfig = { - name: `op_events_${params.data.opId.slice(0, 12)}_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`, - ack_policy: AckPolicy.None, - deliver_policy: sinceEventId > 0 ? DeliverPolicy.StartSequence : DeliverPolicy.New, - replay_policy: ReplayPolicy.Instant, - filter_subject: opEventsSubject(params.data.opId), - max_deliver: 1, - inactive_threshold: nanos(60_000_000_000), - ...(sinceEventId > 0 ? { opt_start_seq: sinceEventId + 1 } : {}), - }; - const info = await jsm.consumers.add(EVENTS_STREAM_NAME, consumerConfig); - consumerName = info.name; - const consumer = await js.consumers.get(EVENTS_STREAM_NAME, info.name); - messages = await consumer.consume(); + unsubscribe = await operationEventStream.subscribe({ + opId: params.data.opId, + sinceEventId, + onEvent: (event) => { + if (closed) return; + if (event.snapshot.opId !== params.data.opId) return; + const nextSignature = JSON.stringify(event.snapshot); + if (nextSignature !== signature) { + current = event.snapshot; + signature = nextSignature; + writeSnapshot(current, event.eventId); + } + if (isTerminalStatus(event.snapshot.status)) { + closeStream(); + } + }, + onError: (error) => { + app.log.warn({ + opId: params.data.opId, + error: toErrorMessage(error), + }, 'op events stream loop error'); + closeStream(); + }, + }); - for await (const msg of messages) { - if (closed) break; - const state = opEventCodec.decode(msg.data); - if (state.opId !== params.data.opId) continue; - - const nextSignature = JSON.stringify(state); - if (nextSignature !== signature) { - current = state; - signature = nextSignature; - writeSnapshot(current, msg.seq); - } - if (isTerminalStatus(state.status)) break; - } + await new Promise((resolve) => { + request.raw.once('close', () => resolve()); + }); } catch (error) { app.log.warn({ opId: params.data.opId, error: toErrorMessage(error), }, 'op events stream loop error'); } finally { - if (messages) { - await messages.close().catch(() => undefined); - } - if (consumerName) { - const { jsm } = await ensureConnected(); - await jsm.consumers.delete(EVENTS_STREAM_NAME, consumerName).catch(() => undefined); - } - } - - if (!reply.raw.writableEnded) { - reply.raw.end(); + closeStream(); } return reply; @@ -1035,21 +1027,14 @@ async function main(): Promise { decoded = input.codec.decode(input.msg.data); const startedAt = Date.now(); const queueWaitMs = safeDurationMs(decoded.queuedAt, startedAt); + const queueWaitTiming = typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; - const runningState: WorkerOperationState = { + await orchestrator.markRunning({ opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - jobId: decoded.jobId, - status: 'running', - queuedAt: decoded.queuedAt, startedAt, updatedAt: startedAt, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }; - - await putOpState(runningState); + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + }); await putJobState({ jobId: decoded.jobId, opId: decoded.opId, @@ -1059,7 +1044,7 @@ async function main(): Promise { timestamp: decoded.queuedAt, startedAt, updatedAt: startedAt, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), ...(latestProgress ? { progress: latestProgress } : {}), }); app.log.info({ @@ -1072,20 +1057,21 @@ async function main(): Promise { }, 'job.started'); const persistRunningState = async (updatedAt: number): Promise => { - const runningOpState: WorkerOperationState = { - opId: decoded!.opId, - opKey: decoded!.opKey, - kind: decoded!.kind, - jobId: decoded!.jobId, - status: 'running', - queuedAt: decoded!.queuedAt, - startedAt, - updatedAt, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }; - - await putOpState(runningOpState); + if (latestProgress) { + await orchestrator.markProgress({ + opId: decoded!.opId, + progress: latestProgress, + updatedAt, + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + }); + } else { + await orchestrator.markRunning({ + opId: decoded!.opId, + startedAt, + updatedAt, + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + }); + } await putJobState({ jobId: decoded!.jobId, opId: decoded!.opId, @@ -1095,7 +1081,7 @@ async function main(): Promise { timestamp: decoded!.queuedAt, startedAt, updatedAt, - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), ...(latestProgress ? { progress: latestProgress } : {}), }); }; @@ -1123,21 +1109,12 @@ async function main(): Promise { : undefined; const now = Date.now(); - const succeededState: WorkerOperationState = { + await orchestrator.markSucceeded({ opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - jobId: decoded.jobId, - status: 'succeeded', - queuedAt: decoded.queuedAt, - startedAt, - updatedAt: now, result: result as WhisperAlignJobResult | PdfLayoutJobResult, + updatedAt: now, ...(resultTiming ? { timing: resultTiming } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }; - - await putOpState(succeededState); + }); await putJobState({ jobId: decoded.jobId, opId: decoded.opId, @@ -1185,22 +1162,30 @@ async function main(): Promise { if (decoded) { const now = Date.now(); const queueWaitMs = safeDurationMs(decoded.queuedAt, now); + const queueWaitTiming = typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; const status: WorkerJobState = hasRetriesLeft ? 'running' : 'failed'; - const opState: WorkerOperationState = { - opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - jobId: decoded.jobId, - status, - queuedAt: decoded.queuedAt, - updatedAt: now, - ...(status === 'failed' ? { error: { message } } : {}), - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }; + const persistOpUpdate = hasRetriesLeft + ? (latestProgress + ? orchestrator.markProgress({ + opId: decoded.opId, + progress: latestProgress, + updatedAt: now, + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + }) + : orchestrator.markRunning({ + opId: decoded.opId, + updatedAt: now, + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + })) + : orchestrator.markFailed({ + opId: decoded.opId, + error: { message }, + updatedAt: now, + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + }); - await putOpState(opState).catch((stateError) => { + await persistOpUpdate.catch((stateError) => { app.log.error({ worker: input.workerLabel, opId: decoded?.opId, @@ -1218,7 +1203,7 @@ async function main(): Promise { timestamp: decoded.queuedAt, updatedAt: now, ...(status === 'failed' ? { error: { message } } : {}), - ...(typeof queueWaitMs === 'number' ? { timing: { queueWaitMs } } : {}), + ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), ...(latestProgress ? { progress: latestProgress } : {}), }).catch((stateError) => { app.log.error({ diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 536b509..abd8e0f 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -13,12 +13,12 @@ import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state- import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; -const SSE_POLL_INTERVAL_MS = 1200; const SSE_KEEPALIVE_MS = 15_000; const SSE_RESYNC_INTERVAL_MS = 30_000; @@ -49,29 +49,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function parseSsePayload(frame: string): string | null { - const lines = frame.replace(/\r\n/g, '\n').split('\n'); - const dataLines: string[] = []; - for (const line of lines) { - if (!line.startsWith('data:')) continue; - dataLines.push(line.slice('data:'.length).trimStart()); - } - if (dataLines.length === 0) return null; - return dataLines.join('\n'); -} - -function parseSseEventId(frame: string): number | null { - const lines = frame.replace(/\r\n/g, '\n').split('\n'); - for (const line of lines) { - if (!line.startsWith('id:')) continue; - const value = Number(line.slice('id:'.length).trim()); - if (Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - } - return null; -} - function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { switch (status) { case 'queued': @@ -361,7 +338,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string while (!closed) { if (!currentOpId) { - await sleep(SSE_POLL_INTERVAL_MS); + await sleep(SSE_RESYNC_INTERVAL_MS); const next = await syncFromDb({ documentId: id, storageUserId, diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 10d4dc2..3be3897 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -1,5 +1,6 @@ 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 type { PdfLayoutJobRequest, @@ -185,30 +186,6 @@ export function buildPdfOpKey(input: PdfLayoutInput): string { ].join('|'); } -function extractSsePayload(frame: string): string | null { - const dataLines: string[] = []; - const normalized = frame.replace(/\r\n/g, '\n'); - for (const line of normalized.split('\n')) { - if (line.startsWith('data:')) { - dataLines.push(line.slice('data:'.length).trimStart()); - } - } - if (dataLines.length === 0) return null; - return dataLines.join('\n'); -} - -function extractSseId(frame: string): number | null { - const normalized = frame.replace(/\r\n/g, '\n'); - for (const line of normalized.split('\n')) { - if (!line.startsWith('id:')) continue; - const value = Number(line.slice('id:'.length).trim()); - if (Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - } - return null; -} - type RetryMeta = { attempt: number, maxAttempts: number, @@ -606,11 +583,11 @@ export class WorkerComputeBackend implements ComputeBackend { const frame = buffer.slice(0, frameEnd); buffer = buffer.slice(frameEnd + 2); - const eventId = extractSseId(frame); + const eventId = parseSseEventId(frame); if (eventId && eventId > 0) { lastEventId = eventId; } - const payload = extractSsePayload(frame); + const payload = parseSsePayload(frame); if (!payload) continue; let event: WorkerOperationEvent | WorkerOperationState; diff --git a/tests/unit/compute-control-plane.spec.ts b/tests/unit/compute-control-plane.spec.ts index 482faa2..7a9e338 100644 --- a/tests/unit/compute-control-plane.spec.ts +++ b/tests/unit/compute-control-plane.spec.ts @@ -1,10 +1,15 @@ import { expect, test } from '@playwright/test'; import type { WorkerOperationRequest, WorkerOperationState } from '../../compute/core/src/api-contracts'; import { + encodeSseFrame, + explainReplacementReason, InMemoryOperationEventStream, InMemoryOperationQueue, InMemoryOperationStateStore, OperationOrchestrator, + parseSseEventId, + parseSsePayload, + shouldReuseExistingOperation, } from '../../compute/core/src/control-plane'; function buildPdfLayoutRequest(opKey: string): WorkerOperationRequest { @@ -150,4 +155,47 @@ test.describe('compute control-plane', () => { expect(op1Events.map((event) => event.eventId)).toEqual([1, 2]); expect(op2Events.map((event) => event.eventId)).toEqual([1]); }); + + test('state-machine helpers return consistent reuse/replacement decisions', () => { + const current: WorkerOperationState = { + opId: 'op-1', + opKey: 'same-op-key', + kind: 'pdf_layout', + jobId: 'job-1', + status: 'running', + queuedAt: 1000, + updatedAt: 2000, + }; + + expect(shouldReuseExistingOperation({ + current, + requestKind: 'pdf_layout', + now: 2500, + opStaleMs: 1_000, + })).toBeTruthy(); + + expect(shouldReuseExistingOperation({ + current, + requestKind: 'pdf_layout', + now: 4005, + opStaleMs: 1_000, + })).toBeFalsy(); + + expect(explainReplacementReason({ + current, + requestKind: 'pdf_layout', + now: 4005, + opStaleMs: 1_000, + })).toBe('stale_running'); + }); + + test('sse helpers encode and decode frame payload and id', () => { + const frame = encodeSseFrame({ + id: 42, + event: 'snapshot', + data: { ok: true }, + }); + expect(parseSseEventId(frame)).toBe(42); + expect(parseSsePayload(frame)).toBe('{"ok":true}'); + }); }); diff --git a/tests/unit/compute-worker-control-plane-jetstream.spec.ts b/tests/unit/compute-worker-control-plane-jetstream.spec.ts index 699534c..641b78e 100644 --- a/tests/unit/compute-worker-control-plane-jetstream.spec.ts +++ b/tests/unit/compute-worker-control-plane-jetstream.spec.ts @@ -10,7 +10,7 @@ import { opStateKvKey, type KvEntryLike, type KvStoreLike, -} from '../../compute/worker/src/control-plane-jetstream'; +} from '../../compute/worker/src/control-plane/jetstream'; class FakeKvStore implements KvStoreLike { private readonly data = new Map(); @@ -64,6 +64,11 @@ class FakeKvStore implements KvStoreLike { class FakeJetStream { private seq = 0; readonly published: Array<{ subject: string; payload: unknown; seq: number }> = []; + readonly consumers = { + get: async () => { + throw new Error('not implemented in fake'); + }, + }; async publish(subject: string, data: Uint8Array): Promise<{ seq: number }> { this.seq += 1; @@ -127,6 +132,13 @@ test.describe('worker jetstream control-plane adapters', () => { }); const events = new JetStreamOperationEventStream({ getJs: async () => js, + getJsm: async () => ({ + consumers: { + add: async () => ({ name: 'noop' }), + delete: async () => true, + }, + }), + eventsStreamName: 'compute_events', }); await queue.enqueue({ @@ -157,7 +169,16 @@ test.describe('worker jetstream control-plane adapters', () => { const js = new FakeJetStream(); const store = new JetStreamOperationStateStore({ getKv: async () => kv }); - const events = new JetStreamOperationEventStream({ getJs: async () => js }); + const events = new JetStreamOperationEventStream({ + getJs: async () => js, + getJsm: async () => ({ + consumers: { + add: async () => ({ name: 'noop' }), + delete: async () => true, + }, + }), + eventsStreamName: 'compute_events', + }); const queue = new JetStreamOperationQueue({ getJs: async () => js, whisperSubject: 'jobs.whisper', From 821d0fdc6ba580a86148df1b7d0b806aea742740 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 15:45:19 -0600 Subject: [PATCH 097/137] build(tsconfig): add path alias for compute-core control-plane module --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 9279c29..2c7a43b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "@/*": ["./src/*"], "@openreader/compute-core": ["./compute/core/src/index.ts"], "@openreader/compute-core/api-contracts": ["./compute/core/src/api-contracts/index.ts"], + "@openreader/compute-core/control-plane": ["./compute/core/src/control-plane/index.ts"], "@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"], "@openreader/compute-core/types": ["./compute/core/src/types/index.ts"] } From f5eb593554f72912c3ad485e2210f1853c1e86c0 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 15:56:39 -0600 Subject: [PATCH 098/137] hard-cut batch A: worker-only app compute backend --- src/lib/server/compute/index.ts | 22 ++------ src/lib/server/compute/index.worker.ts | 20 ------- src/lib/server/compute/local.ts | 77 -------------------------- src/lib/server/compute/mode.ts | 17 ------ src/lib/server/compute/types.ts | 4 +- src/lib/server/compute/worker.ts | 11 +++- src/lib/server/runtime-config.ts | 4 +- 7 files changed, 18 insertions(+), 137 deletions(-) delete mode 100644 src/lib/server/compute/index.worker.ts delete mode 100644 src/lib/server/compute/local.ts delete mode 100644 src/lib/server/compute/mode.ts diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index 25633e4..ecfea22 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -1,24 +1,10 @@ -import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; -import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode'; -import { WorkerComputeBackend } from '@/lib/server/compute/worker'; - -declare const __OPENREADER_COMPUTE_MODE__: 'local' | 'worker' | 'none'; +import type { ComputeBackend } from '@/lib/server/compute/types'; +import { isWorkerClientConfigAvailable, WorkerComputeBackend } from '@/lib/server/compute/worker'; let backendPromise: Promise | null = null; async function createBackend(): Promise { - const bundledMode = - typeof __OPENREADER_COMPUTE_MODE__ === 'undefined' ? 'none' : __OPENREADER_COMPUTE_MODE__; - - if (bundledMode === 'worker') return new WorkerComputeBackend(); - if (bundledMode === 'local') { - const { LocalComputeBackend } = await import('@/lib/server/compute/local'); - return new LocalComputeBackend(); - } - const mode: ComputeMode = readComputeMode(); - if (mode === 'worker') return new WorkerComputeBackend(); - const { LocalComputeBackend } = await import('@/lib/server/compute/local'); - return new LocalComputeBackend(); + return new WorkerComputeBackend(); } export async function getCompute(): Promise { @@ -32,5 +18,5 @@ export async function getCompute(): Promise { } export function isComputeAvailable(): boolean { - return isComputeModeAvailable(); + return isWorkerClientConfigAvailable(); } diff --git a/src/lib/server/compute/index.worker.ts b/src/lib/server/compute/index.worker.ts deleted file mode 100644 index fbbaf7b..0000000 --- a/src/lib/server/compute/index.worker.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ComputeBackend } from '@/lib/server/compute/types'; -import { WorkerComputeBackend } from '@/lib/server/compute/worker'; - -let backendPromise: Promise | null = null; - -export async function getCompute(): Promise { - if (!backendPromise) { - backendPromise = Promise.resolve() - .then(() => new WorkerComputeBackend()) - .catch((error) => { - backendPromise = null; - throw error; - }); - } - return backendPromise; -} - -export function isComputeAvailable(): boolean { - return true; -} diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts deleted file mode 100644 index 001ed1e..0000000 --- a/src/lib/server/compute/local.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; -import { LOCAL_COMPUTE_LIMITER } from '@/lib/server/compute/concurrency-limiter'; -import { getDocumentBlob } from '@/lib/server/documents/blobstore'; -import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; -import { - getComputeTimeoutConfig, - withIdleTimeoutAndHardCap, - withTimeout, -} from '@openreader/compute-core'; -import type { PdfLayoutProgress } from '@openreader/compute-core/api-contracts'; -import { - runPdfLayoutFromPdfBuffer, - runWhisperAlignmentFromAudioBuffer, -} from '@openreader/compute-core/local-runtime'; - -export class LocalComputeBackend implements ComputeBackend { - readonly mode = 'local' as const; - private readonly timeoutConfig = getComputeTimeoutConfig(); - - async alignWords(input: WhisperAlignInput): Promise { - return LOCAL_COMPUTE_LIMITER.run(async () => { - let audioBuffer = input.audioBuffer ?? null; - if (!audioBuffer && input.audioObjectKey) { - const bytes = new Uint8Array(await getTtsSegmentAudioObject(input.audioObjectKey)); - audioBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - } - if (!audioBuffer) { - throw new Error('Local compute alignment requires audioBuffer or audioObjectKey'); - } - return withTimeout( - runWhisperAlignmentFromAudioBuffer({ - audioBuffer, - text: input.text, - cacheKey: input.cacheKey, - lang: input.lang, - }), - this.timeoutConfig.whisperTimeoutMs, - 'local whisper alignment job', - ); - }); - } - - async parsePdfLayout(input: PdfLayoutInput) { - return LOCAL_COMPUTE_LIMITER.run(async () => { - let pdfBytes = input.pdfBytes ?? null; - if (!pdfBytes && input.documentId && typeof input.namespace !== 'undefined') { - const bytes = new Uint8Array(await getDocumentBlob(input.documentId, input.namespace)); - pdfBytes = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - } - if (!pdfBytes) { - throw new Error('Local compute PDF layout requires pdfBytes or (documentId + namespace)'); - } - - return { - parsed: (await withIdleTimeoutAndHardCap({ - idleTimeoutMs: Math.max(this.timeoutConfig.pdfTimeoutMs, 1_000), - hardCapMs: this.timeoutConfig.pdfHardCapMs, - label: 'local pdf layout job', - run: async (touchProgress) => runPdfLayoutFromPdfBuffer({ - documentId: input.documentId, - pdfBytes, - onPageParsed: (page) => { - touchProgress(); - const progress: PdfLayoutProgress = { - totalPages: page.totalPages, - pagesParsed: page.pageNumber, - currentPage: page.pageNumber, - phase: 'infer', - }; - return input.onProgress?.(progress); - }, - }), - })).parsed, - }; - }); - } -} diff --git a/src/lib/server/compute/mode.ts b/src/lib/server/compute/mode.ts deleted file mode 100644 index 5c2b83d..0000000 --- a/src/lib/server/compute/mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ComputeMode } from '@/lib/server/compute/types'; - -export function readComputeMode(): ComputeMode { - const envValue = process.env.COMPUTE_MODE; - const raw = (envValue || '').trim().toLowerCase(); - if (!raw) return 'local'; - if (raw === 'local' || raw === 'worker') return raw; - throw new Error( - `Invalid COMPUTE_MODE="${envValue}". Expected "local" or "worker".`, - ); -} - -export function isComputeModeAvailable(): boolean { - // Throws on invalid values so startup/runtime config fails fast. - readComputeMode(); - return true; -} diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index f93a8f3..5f21b57 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -6,7 +6,7 @@ import type { import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; -export type ComputeMode = 'local' | 'worker'; +export type ComputeMode = 'worker'; export interface WhisperAlignInput extends WhisperAlignJobBase { audioBuffer?: TTSAudioBuffer; @@ -32,7 +32,7 @@ export type PdfLayoutResult = | { parsed?: never; parsedObjectKey: string }; export interface ComputeBackend { - mode: ComputeMode; + mode: 'worker'; alignWords(input: WhisperAlignInput): Promise; parsePdfLayout(input: PdfLayoutInput): Promise; } diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts index 3be3897..3f4dcf5 100644 --- a/src/lib/server/compute/worker.ts +++ b/src/lib/server/compute/worker.ts @@ -93,7 +93,7 @@ function opSummary(value: unknown): Record { function readRequiredEnv(name: string): string { const value = process.env[name]?.trim(); - if (!value) throw new Error(`${name} is required when COMPUTE_MODE=worker`); + if (!value) throw new Error(`${name} is required for compute worker client`); return value; } @@ -127,6 +127,15 @@ export function getWorkerClientConfigFromEnv(): { baseUrl: string; token: string }; } +export function isWorkerClientConfigAvailable(): boolean { + try { + getWorkerClientConfigFromEnv(); + return true; + } catch { + return false; + } +} + function parseRetryAfterMs(value: string | null): number | null { if (!value) return null; const asNum = Number(value); diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index 14be70f..87889d8 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,7 +6,7 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; -import { isComputeModeAvailable } from '@/lib/server/compute/mode'; +import { isComputeAvailable } from '@/lib/server/compute'; export type ResolvedRuntimeConfig = RuntimeConfig & { computeAvailable: boolean; @@ -29,7 +29,7 @@ export async function getResolvedRuntimeConfig(): Promise const values = await getRuntimeConfig(); return { ...values, - computeAvailable: isComputeModeAvailable(), + computeAvailable: isComputeAvailable(), }; } From f77ee8cfd46a319a37852ade90e262da4bce68a2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 15:58:14 -0600 Subject: [PATCH 099/137] hard-cut batch B: worker-proxy parsed SSE and remove app progress bus --- .../api/documents/[id]/parsed/events/route.ts | 97 +-------------- .../documents/parse-progress-bus.memory.ts | 36 ------ .../server/documents/parse-progress-bus.ts | 16 --- .../documents/parse-progress-bus.types.ts | 14 --- .../server/documents/parse-progress-events.ts | 18 --- .../server/documents/parse-state-healing.ts | 23 ---- src/lib/server/jobs/user-pdf-layout-job.ts | 114 +++--------------- tests/unit/parse-progress-bus.spec.ts | 57 --------- 8 files changed, 22 insertions(+), 353 deletions(-) delete mode 100644 src/lib/server/documents/parse-progress-bus.memory.ts delete mode 100644 src/lib/server/documents/parse-progress-bus.ts delete mode 100644 src/lib/server/documents/parse-progress-bus.types.ts delete mode 100644 src/lib/server/documents/parse-progress-events.ts delete mode 100644 tests/unit/parse-progress-bus.spec.ts diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index abd8e0f..9660453 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -3,10 +3,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { readComputeMode } from '@/lib/server/compute/mode'; import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; -import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; -import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; @@ -161,22 +158,16 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } const initialState = await toSnapshotState(row); - const computeMode = readComputeMode(); - const bus = computeMode === 'local' ? await getParseProgressBus() : null; - - const workerCfg = computeMode === 'worker' ? getWorkerClientConfigFromEnv() : null; + const workerCfg = getWorkerClientConfigFromEnv(); const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { let closed = false; - let unsubscribe: (() => void) | null = null; let keepaliveTimer: ReturnType | null = null; let resyncTimer: ReturnType | null = null; let workerAbort: AbortController | null = null; - const allowedUserHashes = new Set(allowedUserIds.map((candidate) => hashUserId(candidate))); - const writeSnapshot = (snapshot: ParsedSnapshot): void => { controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`)); }; @@ -192,10 +183,6 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string clearInterval(resyncTimer); resyncTimer = null; } - if (unsubscribe) { - unsubscribe(); - unsubscribe = null; - } if (workerAbort) { workerAbort.abort(); workerAbort = null; @@ -207,85 +194,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } }; - const runLocalRealtime = async () => { - let current = initialState.snapshot; - let signature = JSON.stringify(current); - writeSnapshot(current); - - if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { - closeStream(); - return; - } - - keepaliveTimer = setInterval(() => { - if (closed) return; - controller.enqueue(encoder.encode(': keepalive\n\n')); - }, SSE_KEEPALIVE_MS); - - resyncTimer = setInterval(() => { - if (closed) return; - void syncFromDb({ - documentId: id, - storageUserId, - allowedUserIds, - signature, - writeSnapshot, - }).then((next) => { - if (closed) return; - if (!next) { - closeStream(); - return; - } - current = next.snapshot; - signature = next.signature; - if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { - closeStream(); - } - }).catch((error) => { - if (closed) return; - controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); - closeStream(); - }); - }, SSE_RESYNC_INTERVAL_MS); - - if (!bus) throw new Error('Local parse progress bus unavailable'); - const nextUnsubscribe = await bus.subscribe({ - documentId: id, - userIdHashes: allowedUserHashes, - onEvent: async () => { - const next = await syncFromDb({ - documentId: id, - storageUserId, - allowedUserIds, - signature, - writeSnapshot, - }); - if (!next) { - closeStream(); - return; - } - current = next.snapshot; - signature = next.signature; - if (current.parseStatus === 'ready' || current.parseStatus === 'failed') { - closeStream(); - } - }, - onError: (error) => { - if (closed) return; - controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); - closeStream(); - }, - }); - if (closed) { - nextUnsubscribe(); - return; - } - unsubscribe = nextUnsubscribe; - }; - const runWorkerProxy = async () => { - if (!workerCfg) throw new Error('Worker mode selected but worker config is unavailable'); - let current = initialState.snapshot; let signature = JSON.stringify(current); let currentOpId = initialState.opId; @@ -480,9 +389,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } }; - const run = computeMode === 'local' ? runLocalRealtime : runWorkerProxy; - - void run() + void runWorkerProxy() .catch((error) => { if (!closed) { controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); diff --git a/src/lib/server/documents/parse-progress-bus.memory.ts b/src/lib/server/documents/parse-progress-bus.memory.ts deleted file mode 100644 index 9a707f1..0000000 --- a/src/lib/server/documents/parse-progress-bus.memory.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { EventEmitter } from 'node:events'; -import type { ParseProgressBus, ParseProgressBusSubscribeInput } from '@/lib/server/documents/parse-progress-bus.types'; -import type { ParseProgressEvent } from '@/lib/server/documents/parse-progress-events'; - -type Listener = (event: ParseProgressEvent) => void; - -function topicForDocument(documentId: string): string { - return `parse-progress.${documentId}`; -} - -export class InMemoryParseProgressBus implements ParseProgressBus { - readonly kind = 'memory' as const; - private readonly emitter = new EventEmitter(); - - constructor() { - this.emitter.setMaxListeners(0); - } - - async publish(event: ParseProgressEvent): Promise { - this.emitter.emit(topicForDocument(event.documentId), event); - } - - async subscribe(input: ParseProgressBusSubscribeInput): Promise<() => void> { - const listener: Listener = (event) => { - if (!input.userIdHashes.has(event.userIdHash)) return; - Promise.resolve(input.onEvent(event)).catch((error) => { - input.onError?.(error); - }); - }; - const topic = topicForDocument(input.documentId); - this.emitter.on(topic, listener); - return () => { - this.emitter.off(topic, listener); - }; - } -} diff --git a/src/lib/server/documents/parse-progress-bus.ts b/src/lib/server/documents/parse-progress-bus.ts deleted file mode 100644 index df6e27b..0000000 --- a/src/lib/server/documents/parse-progress-bus.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { InMemoryParseProgressBus } from '@/lib/server/documents/parse-progress-bus.memory'; -import type { ParseProgressBus } from '@/lib/server/documents/parse-progress-bus.types'; - -let busPromise: Promise | null = null; - -export async function getParseProgressBus(): Promise { - if (busPromise) return busPromise; - busPromise = Promise.resolve(new InMemoryParseProgressBus()); - return busPromise; -} - -export function __resetParseProgressBusForTests(): void { - busPromise = null; -} - -export type { ParseProgressBus, ParseProgressBusSubscribeInput } from '@/lib/server/documents/parse-progress-bus.types'; diff --git a/src/lib/server/documents/parse-progress-bus.types.ts b/src/lib/server/documents/parse-progress-bus.types.ts deleted file mode 100644 index 423d257..0000000 --- a/src/lib/server/documents/parse-progress-bus.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ParseProgressEvent } from '@/lib/server/documents/parse-progress-events'; - -export interface ParseProgressBusSubscribeInput { - documentId: string; - userIdHashes: Set; - onEvent: (event: ParseProgressEvent) => void | Promise; - onError?: (error: unknown) => void; -} - -export interface ParseProgressBus { - readonly kind: 'memory'; - publish(event: ParseProgressEvent): Promise; - subscribe(input: ParseProgressBusSubscribeInput): Promise<() => void>; -} diff --git a/src/lib/server/documents/parse-progress-events.ts b/src/lib/server/documents/parse-progress-events.ts deleted file mode 100644 index f43fc91..0000000 --- a/src/lib/server/documents/parse-progress-events.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; - -export interface ParseProgressEvent { - version: 1; - documentId: string; - userIdHash: string; - parseStatus: PdfParseStatus; - parseProgress: PdfParseProgress | null; - updatedAt: number; - opId?: string; - jobId?: string; - error?: string | null; -} - -export function hashUserId(userId: string): string { - return createHash('sha256').update(userId).digest('hex').slice(0, 24); -} diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts index 909bbe3..5824e37 100644 --- a/src/lib/server/documents/parse-state-healing.ts +++ b/src/lib/server/documents/parse-state-healing.ts @@ -8,8 +8,6 @@ import { stringifyDocumentParseState, type DocumentParseState, } from '@/lib/server/documents/parse-state'; -import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; -import { hashUserId } from '@/lib/server/documents/parse-progress-events'; export async function healStaleDocumentParseState(input: { documentId: string; @@ -31,26 +29,5 @@ export async function healStaleDocumentParseState(input: { .set({ parseState: stringifyDocumentParseState(nextState) }) .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); - try { - const bus = await getParseProgressBus(); - await bus.publish({ - version: 1, - documentId: input.documentId, - userIdHash: hashUserId(input.userId), - parseStatus: nextState.status, - parseProgress: nextState.progress ?? null, - updatedAt: nextState.updatedAt ?? Date.now(), - ...(nextState.opId ? { opId: nextState.opId } : {}), - ...(nextState.jobId ? { jobId: nextState.jobId } : {}), - ...(nextState.error ? { error: nextState.error } : {}), - }); - } catch (error) { - console.warn('[parse-state-healing] failed to publish stale state event', { - documentId: input.documentId, - userId: input.userId, - error: error instanceof Error ? error.message : String(error), - }); - } - return nextState; } diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 1b75556..0dd7eba 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -8,8 +8,6 @@ import { stringifyDocumentParseState, type DocumentParseState, } from '@/lib/server/documents/parse-state'; -import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; -import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { getCompute } from '@/lib/server/compute'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; @@ -29,7 +27,7 @@ const running = new Set(); const FOLLOWER_WAIT_TIMEOUT_MS = 180_000; const FOLLOWER_POLL_MS = 1_200; -const WORKER_PROGRESS_DB_THROTTLE_MS = 10_000; +const PROGRESS_DB_THROTTLE_MS = 10_000; type ParseRow = { userId: string; @@ -122,41 +120,6 @@ async function updateParseStateForUsers(input: { ); } -function normalizeEventState(state: DocumentParseState): DocumentParseState { - return parseDocumentParseState(stringifyDocumentParseState(state)); -} - -async function emitParseStateEvents(input: { - documentId: string; - userIds: string[]; - state: DocumentParseState; -}): Promise { - if (input.userIds.length === 0) return; - const normalized = normalizeEventState(input.state); - const bus = await getParseProgressBus(); - - const publishResults = await Promise.allSettled(input.userIds.map(async (userId) => bus.publish({ - version: 1, - documentId: input.documentId, - userIdHash: hashUserId(userId), - parseStatus: normalized.status, - parseProgress: normalized.progress ?? null, - updatedAt: normalized.updatedAt ?? Date.now(), - ...(normalized.opId ? { opId: normalized.opId } : {}), - ...(normalized.jobId ? { jobId: normalized.jobId } : {}), - ...(normalized.error ? { error: normalized.error } : {}), - }))); - - publishResults.forEach((result, index) => { - if (result.status === 'fulfilled') return; - console.warn('[parsePdfJob] failed to publish parse progress event', { - documentId: input.documentId, - userId: input.userIds[index], - error: result.reason instanceof Error ? result.reason.message : String(result.reason), - }); - }); -} - async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise { const deadline = Date.now() + FOLLOWER_WAIT_TIMEOUT_MS; @@ -175,11 +138,6 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise parseState: stringifyDocumentParseState(readyState), parsedJsonKey: ready.parsedJsonKey, }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: [input.userId], - state: readyState, - }); return; } @@ -198,11 +156,6 @@ async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise userIds: [input.userId], parseState: stringifyDocumentParseState(nextFailedState), }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: [input.userId], - state: nextFailedState, - }); return; } @@ -240,11 +193,6 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise parseState: stringifyDocumentParseState(readyState), parsedJsonKey: ready.parsedJsonKey, }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: [input.userId], - state: readyState, - }); return; } } @@ -289,21 +237,15 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise userIds: cohortUserIds, parseState: runningState, }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: cohortUserIds, - state: runningStateData, - }); const compute = await getCompute(); - const isWorkerCompute = compute.mode === 'worker'; let activeOpId: string = claimOpId; let activeJobId: string | undefined; - let lastWorkerProgressWriteAt = 0; - let lastWorkerSnapshotWriteAt = 0; - let lastWorkerSnapshotStatus: 'pending' | 'running' | null = null; - let lastWorkerSnapshotOpId: string = claimOpId; - let lastWorkerSnapshotJobId: string | undefined; + let lastProgressWriteAt = 0; + let lastSnapshotWriteAt = 0; + let lastSnapshotStatus: 'pending' | 'running' | null = null; + let lastSnapshotOpId: string = claimOpId; + let lastSnapshotJobId: string | undefined; const persistRunningState = async (state: DocumentParseState): Promise => { await updateParseStateForUsers({ @@ -311,15 +253,9 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise userIds: cohortUserIds, parseState: stringifyDocumentParseState(state), }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: cohortUserIds, - state, - }); }; const onWorkerSnapshot = async (snapshot: WorkerOperationState): Promise => { - if (!isWorkerCompute) return; if (snapshot.opId) activeOpId = snapshot.opId; if (snapshot.jobId) activeJobId = snapshot.jobId; @@ -332,11 +268,11 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise const now = Date.now(); const forceWrite = ( - mappedStatus !== lastWorkerSnapshotStatus - || activeOpId !== lastWorkerSnapshotOpId - || activeJobId !== lastWorkerSnapshotJobId + mappedStatus !== lastSnapshotStatus + || activeOpId !== lastSnapshotOpId + || activeJobId !== lastSnapshotJobId ); - if (!forceWrite && (now - lastWorkerSnapshotWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS) { + if (!forceWrite && (now - lastSnapshotWriteAt) < PROGRESS_DB_THROTTLE_MS) { return; } @@ -348,20 +284,19 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise ...(activeJobId ? { jobId: activeJobId } : {}), }; await persistRunningState(nextState); - lastWorkerSnapshotWriteAt = now; - lastWorkerSnapshotStatus = mappedStatus; - lastWorkerSnapshotOpId = activeOpId; - lastWorkerSnapshotJobId = activeJobId; + lastSnapshotWriteAt = now; + lastSnapshotStatus = mappedStatus; + lastSnapshotOpId = activeOpId; + lastSnapshotJobId = activeJobId; }; const writeProgress = async (progress: PdfLayoutProgress): Promise => { - if (isWorkerCompute) { - const now = Date.now(); - if ((now - lastWorkerProgressWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS && progress.pagesParsed < progress.totalPages) { - return; - } - lastWorkerProgressWriteAt = now; + const now = Date.now(); + if ((now - lastProgressWriteAt) < PROGRESS_DB_THROTTLE_MS && progress.pagesParsed < progress.totalPages) { + return; } + lastProgressWriteAt = now; + const runningProgressState: DocumentParseState = { status: 'running', progress: { @@ -376,6 +311,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise }; await persistRunningState(runningProgressState); }; + const layout = await compute.parsePdfLayout({ documentId: input.documentId, namespace: input.namespace, @@ -406,11 +342,6 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise parseState: stringifyDocumentParseState(readyState), parsedJsonKey, }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: readyUserIds, - state: readyState, - }); // Best-effort cache invalidation should not block parse readiness. for (const userId of readyUserIds) { @@ -454,11 +385,6 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise userIds: failedUserIds, parseState: stringifyDocumentParseState(failedState), }); - await emitParseStateEvents({ - documentId: input.documentId, - userIds: failedUserIds, - state: failedState, - }); } catch (statusError) { console.error('[parsePdfJob] failed to write parse status', { documentId: input.documentId, diff --git a/tests/unit/parse-progress-bus.spec.ts b/tests/unit/parse-progress-bus.spec.ts deleted file mode 100644 index 754c71d..0000000 --- a/tests/unit/parse-progress-bus.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { __resetParseProgressBusForTests, getParseProgressBus } from '../../src/lib/server/documents/parse-progress-bus'; -import { InMemoryParseProgressBus } from '../../src/lib/server/documents/parse-progress-bus.memory'; -import { hashUserId, type ParseProgressEvent } from '../../src/lib/server/documents/parse-progress-events'; - -test.describe('parse progress bus', () => { - test.afterEach(() => { - __resetParseProgressBusForTests(); - }); - - test('uses in-memory singleton bus', async () => { - const busA = await getParseProgressBus(); - const busB = await getParseProgressBus(); - expect(busA.kind).toBe('memory'); - expect(busA).toBe(busB); - }); - - test('in-memory bus publishes only to subscribed user hashes', async () => { - const bus = new InMemoryParseProgressBus(); - const documentId = 'd'.repeat(64); - const wantedHash = hashUserId('user-a'); - const otherHash = hashUserId('user-b'); - const received: ParseProgressEvent[] = []; - - const close = await bus.subscribe({ - documentId, - userIdHashes: new Set([wantedHash]), - onEvent: (event) => { - received.push(event); - }, - }); - - await bus.publish({ - version: 1, - documentId, - userIdHash: otherHash, - parseStatus: 'running', - parseProgress: { totalPages: 4, pagesParsed: 1, currentPage: 2, phase: 'infer' }, - updatedAt: Date.now(), - }); - - await bus.publish({ - version: 1, - documentId, - userIdHash: wantedHash, - parseStatus: 'running', - parseProgress: { totalPages: 4, pagesParsed: 2, currentPage: 3, phase: 'infer' }, - updatedAt: Date.now(), - opId: 'op-1', - }); - - close(); - expect(received).toHaveLength(1); - expect(received[0].userIdHash).toBe(wantedHash); - expect(received[0].opId).toBe('op-1'); - }); -}); From fb0c95dfb11c6c4f4e6e19b3a31e08312e8d001b Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 16:00:26 -0600 Subject: [PATCH 100/137] hard-cut batch D: enforce worker bundle isolation and CI guard --- .github/workflows/playwright.yml | 4 +++ next.config.ts | 39 +++------------------- package.json | 1 + scripts/check-next-server-bundle.mjs | 50 ++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 scripts/check-next-server-bundle.mjs diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index bd28673..f266b8b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -36,6 +36,10 @@ jobs: weed version - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build app and enforce bundle guard + run: | + pnpm build + pnpm build:bundle-guard - name: Verify ffprobe run: ffprobe -version - name: Install Playwright Browsers diff --git a/next.config.ts b/next.config.ts index 7beea83..b2952b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,4 @@ import type { NextConfig } from "next"; -import path from "node:path"; -import { createRequire } from "node:module"; - -type DefinePluginCtor = new (defs: Record) => unknown; -const require = createRequire(import.meta.url); -const { DefinePlugin } = require('webpack') as { DefinePlugin: DefinePluginCtor }; const securityHeaders = [ { key: 'X-Content-Type-Options', value: 'nosniff' }, @@ -21,11 +15,7 @@ const securityHeaders = [ }, ]; -const computeModeRaw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase(); -const computeMode = computeModeRaw === 'none' || computeModeRaw === 'worker' || computeModeRaw === 'local' - ? computeModeRaw - : 'local'; -const bundleWorkerCompute = computeMode === 'worker'; +const bundleWorkerCompute = true; const serverExternalPackages = [ '@napi-rs/canvas', 'better-sqlite3', @@ -48,7 +38,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - transpilePackages: !bundleWorkerCompute ? ['@openreader/compute-core'] : [], + transpilePackages: [], serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ @@ -73,35 +63,14 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { '/*': [ './docstore/**/*', - ...(bundleWorkerCompute - ? [ - './node_modules/onnxruntime-node/**/*', - './node_modules/@huggingface/tokenizers/**/*', - ] - : []), + './node_modules/onnxruntime-node/**/*', + './node_modules/@huggingface/tokenizers/**/*', ], }, webpack: (config, { isServer }) => { if (isServer && bundleWorkerCompute) { - config.plugins = config.plugins || []; - config.plugins.push( - new DefinePlugin({ - __OPENREADER_COMPUTE_MODE__: JSON.stringify('worker'), - }), - ); - } - if (isServer && bundleWorkerCompute) { - const workerComputeEntry = path.resolve(__dirname, 'src/lib/server/compute/index.worker.ts'); - const computeIndexTs = path.resolve(__dirname, 'src/lib/server/compute/index.ts'); - const computeIndexNoExt = path.resolve(__dirname, 'src/lib/server/compute/index'); - const computeDir = path.resolve(__dirname, 'src/lib/server/compute'); config.resolve.alias = { ...(config.resolve.alias || {}), - '@/lib/server/compute$': workerComputeEntry, - '@/lib/server/compute/index$': workerComputeEntry, - [`${computeIndexTs}$`]: workerComputeEntry, - [`${computeIndexNoExt}$`]: workerComputeEntry, - [`${computeDir}$`]: workerComputeEntry, '@openreader/compute-core/local-runtime$': false, 'onnxruntime-node$': false, '@huggingface/tokenizers$': false, diff --git a/package.json b/package.json index 838d117..20f37f1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", "dev:raw": "next dev --turbopack -p 3003", "build": "next build", + "build:bundle-guard": "node scripts/check-next-server-bundle.mjs", "start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw", "start:raw": "next start -p 3003", "lint": "next lint", diff --git a/scripts/check-next-server-bundle.mjs b/scripts/check-next-server-bundle.mjs new file mode 100644 index 0000000..9621cb4 --- /dev/null +++ b/scripts/check-next-server-bundle.mjs @@ -0,0 +1,50 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const serverDir = path.join(root, '.next', 'server'); + +if (!fs.existsSync(serverDir)) { + console.error('[bundle-guard] Missing .next/server. Run `pnpm build` first.'); + process.exit(1); +} + +const forbidden = [ + 'onnxruntime-node', + '@huggingface/tokenizers', + '@openreader/compute-core/local-runtime', +]; + +const includeExt = new Set(['.js', '.mjs', '.cjs']); +const failures = []; + +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name).toLowerCase(); + if (!includeExt.has(ext)) continue; + const text = fs.readFileSync(fullPath, 'utf8'); + for (const pattern of forbidden) { + if (text.includes(pattern)) { + failures.push({ file: fullPath, pattern }); + } + } + } +} + +walk(serverDir); + +if (failures.length > 0) { + console.error('[bundle-guard] Forbidden compute deps detected in Next server bundle:'); + for (const failure of failures) { + console.error(`- ${failure.pattern} in ${path.relative(root, failure.file)}`); + } + process.exit(1); +} + +console.info('[bundle-guard] OK: no forbidden compute deps in .next/server'); From c0f0438fff40413ec4f4fe768ce5084ba934a7cb Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 16:25:39 -0600 Subject: [PATCH 101/137] hard-cut batch E: embedded nats+worker startup in single-container entrypoint --- Dockerfile | 7 ++ scripts/openreader-entrypoint.mjs | 117 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/Dockerfile b/Dockerfile index f48c16d..7d038f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,10 @@ RUN cp "$(command -v weed)" /tmp/weed && \ (wget -qO /tmp/SeaweedFS-LICENSE.txt "https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/LICENSE" || \ wget -qO /tmp/SeaweedFS-LICENSE.txt "https://raw.githubusercontent.com/seaweedfs/seaweedfs/main/LICENSE") +# Stage 1b: extract nats-server binary for embedded single-container worker mode. +FROM nats:2.11-alpine AS nats-builder +RUN cp "$(command -v nats-server)" /tmp/nats-server + # Stage 2: build the Next.js app FROM node:lts-alpine AS app-builder @@ -63,6 +67,9 @@ COPY --from=app-builder /app/compute/core/src/pdf/assets/LICENSE.txt /licenses/p # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed +# Copy nats-server binary for embedded local JetStream. +COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server +RUN chmod +x /usr/local/bin/nats-server # Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts. COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 214201b..000719e 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -137,6 +137,12 @@ function hasWeedBinary() { return true; } +function hasNatsBinary() { + const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' }); + if (probe.error) return false; + return true; +} + async function waitForEndpoint(url, timeoutSeconds) { const waitMs = Math.max(1, timeoutSeconds) * 1000; const deadline = Date.now() + waitMs; @@ -314,10 +320,18 @@ async function main() { const runtimeEnv = { ...process.env }; let weedProc = null; let weedExitPromise = Promise.resolve(); + let natsProc = null; + let natsExitPromise = Promise.resolve(); + let workerProc = null; + let workerExitPromise = Promise.resolve(); let appProc = null; let shutdownPromise = null; let stopWeedStdoutForward = () => { }; let stopWeedStderrForward = () => { }; + let stopNatsStdoutForward = () => { }; + let stopNatsStderrForward = () => { }; + let stopWorkerStdoutForward = () => { }; + let stopWorkerStderrForward = () => { }; let didExit = false; const exitOnce = (code) => { @@ -331,11 +345,19 @@ async function main() { shutdownPromise = (async () => { await Promise.all([ terminateChild(appProc, signal, 4000), + terminateChild(workerProc, 'SIGTERM', 4000, true), + terminateChild(natsProc, 'SIGTERM', 4000), terminateChild(weedProc, 'SIGTERM', 4000), ]); await weedExitPromise; + await natsExitPromise; + await workerExitPromise; stopWeedStdoutForward(); stopWeedStderrForward(); + stopNatsStdoutForward(); + stopNatsStderrForward(); + stopWorkerStdoutForward(); + stopWorkerStderrForward(); })(); return shutdownPromise; }; @@ -420,6 +442,101 @@ async function main() { } } + const embeddedWorkerPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_COMPUTE_WORKER_PORT, '8081'), 10); + const embeddedNatsPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_PORT, '4222'), 10); + const embeddedNatsMonitorPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_MONITOR_PORT, '8222'), 10); + const shouldStartEmbeddedWorker = resolveBooleanEnv( + runtimeEnv, + 'START_EMBEDDED_COMPUTE_WORKER', + isRunningInDocker() && !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()), + ); + + if (shouldStartEmbeddedWorker) { + if (!hasNatsBinary()) { + throw new Error('START_EMBEDDED_COMPUTE_WORKER=true but `nats-server` binary is not available in PATH.'); + } + + runtimeEnv.NATS_URL = withDefault(runtimeEnv.NATS_URL, `nats://127.0.0.1:${embeddedNatsPort}`); + runtimeEnv.COMPUTE_WORKER_URL = withDefault(runtimeEnv.COMPUTE_WORKER_URL, `http://127.0.0.1:${embeddedWorkerPort}`); + runtimeEnv.COMPUTE_WORKER_TOKEN = withDefault( + runtimeEnv.COMPUTE_WORKER_TOKEN, + randomBytes(24).toString('base64url'), + ); + runtimeEnv.COMPUTE_WORKER_HOST = withDefault(runtimeEnv.COMPUTE_WORKER_HOST, '127.0.0.1'); + runtimeEnv.COMPUTE_NATS_REPLICAS = withDefault(runtimeEnv.COMPUTE_NATS_REPLICAS, '1'); + + const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, 'docstore/nats/jetstream'); + fs.mkdirSync(natsStoreDir, { recursive: true }); + + console.log(`Starting embedded nats-server on 127.0.0.1:${embeddedNatsPort}...`); + natsProc = spawn( + 'nats-server', + [ + '-js', + '-sd', natsStoreDir, + '-a', '127.0.0.1', + '-p', String(embeddedNatsPort), + '-m', String(embeddedNatsMonitorPort), + ], + { + env: runtimeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + stopNatsStdoutForward = forwardChildStream(natsProc.stdout, process.stdout); + stopNatsStderrForward = forwardChildStream(natsProc.stderr, process.stderr); + natsExitPromise = once(natsProc, 'exit').then(() => undefined).catch(() => undefined); + natsProc.on('exit', (code, signal) => { + if (typeof code === 'number' && code !== 0) { + console.error(`Embedded nats-server exited with code ${code}.`); + return; + } + if (signal) { + console.error(`Embedded nats-server exited due to signal ${signal}.`); + } + }); + natsProc.on('error', (error) => { + console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`); + }); + await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20); + console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`); + + console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`); + const workerEnv = { + ...runtimeEnv, + PORT: String(embeddedWorkerPort), + }; + workerProc = spawn( + 'pnpm', + ['--filter', '@openreader/compute-worker', 'start'], + { + env: workerEnv, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + detached: process.platform !== 'win32', + }, + ); + stopWorkerStdoutForward = forwardChildStream(workerProc.stdout, process.stdout); + stopWorkerStderrForward = forwardChildStream(workerProc.stderr, process.stderr); + workerExitPromise = once(workerProc, 'exit').then(() => undefined).catch(() => undefined); + workerProc.on('exit', (code, signal) => { + if (typeof code === 'number' && code !== 0) { + console.error(`Embedded compute-worker exited with code ${code}.`); + return; + } + if (signal) { + console.error(`Embedded compute-worker exited due to signal ${signal}.`); + } + }); + workerProc.on('error', (error) => { + console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`); + }); + await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30); + console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`); + } else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) { + throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.'); + } + const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); appProc = child; const exitInfo = await exitPromise; From 012cb63de641bcc95c3a90b065a9f7cef4e3cc6a Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 16:28:40 -0600 Subject: [PATCH 102/137] hard-cut batch F: remove COMPUTE_MODE docs and env references --- .env.example | 26 ++++++------ README.md | 2 +- docs-site/docs/configure/admin-panel.md | 2 +- docs-site/docs/deploy/compute-worker.md | 10 ++--- docs-site/docs/deploy/local-development.md | 15 +++---- docs-site/docs/deploy/vercel-deployment.md | 9 ++-- docs-site/docs/introduction.md | 2 +- .../docs/reference/environment-variables.md | 41 ++++++------------- docs-site/docs/reference/stack.md | 2 +- 9 files changed, 44 insertions(+), 65 deletions(-) diff --git a/.env.example b/.env.example index 99232c5..0171ff3 100644 --- a/.env.example +++ b/.env.example @@ -77,29 +77,29 @@ RUN_FS_MIGRATIONS= IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= -# Heavy compute backend mode for ONNX whisper alignment + PDF layout parsing. -# local = run compute in-process (default) -# worker = external durable worker mode (requires NATS JetStream-backed compute-worker service) -# Parse progress transport behavior: -# - local mode: in-process memory event bus inside app server -# - worker mode: app proxies compute-worker op events via SSE -# No app-layer NATS configuration is required for parse progress. -COMPUTE_MODE=local -# Required when COMPUTE_MODE=worker +# Heavy compute is always worker-backed. +# App server calls compute-worker over HTTP for ONNX whisper alignment + PDF layout parsing. +# In single-container self-host setups, run compute-worker + NATS alongside the app and +# point COMPUTE_WORKER_URL at that internal worker endpoint. +# Required: # COMPUTE_WORKER_URL=http://localhost:8081 # COMPUTE_WORKER_TOKEN=local-compute-token +# Optional embedded worker stack controls (used by scripts/openreader-entrypoint.mjs): +# START_EMBEDDED_COMPUTE_WORKER=true +# EMBEDDED_COMPUTE_WORKER_PORT=8081 +# EMBEDDED_NATS_PORT=4222 +# EMBEDDED_NATS_MONITOR_PORT=8222 +# EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream +# NATS_URL=nats://127.0.0.1:4222 # Optional shared compute timeouts used by: -# - local compute runtime # - worker compute runtime # - worker client wait budgets -# Optional local compute concurrency (in-process compute only). -# Worker mode uses compute-worker service `COMPUTE_JOB_CONCURRENCY` instead. +# Worker-side concurrency is configured in compute-worker service via `COMPUTE_JOB_CONCURRENCY`. # COMPUTE_JOB_CONCURRENCY=1 # COMPUTE_WHISPER_TIMEOUT_MS=30000 # COMPUTE_PDF_TIMEOUT_MS=300000 # COMPUTE_OP_STALE_MS=1800000 # Worker mode requires worker-reachable shared object storage. -# Non-exposed embedded weed mini is not supported in worker mode. # Optional Whisper ONNX base URL override (must contain all expected files) # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main diff --git a/README.md b/README.md index 1e6b1ad..44ab3b7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights - 🧱 **Layout-aware PDF parsing** with PP-DocLayoutV3 (ONNX) — structured block detection, cross-page stitching, and geometry-based highlighting for precise read-along sync. -- ⏱️ **Word-by-word highlighting** via built-in ONNX Whisper alignment — no external dependencies, runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable NATS JetStream-backed worker (`COMPUTE_MODE=worker`). +- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed). - ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback. - 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index a887d4b..3d36571 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -78,7 +78,7 @@ Runtime-editable settings, one row per key: | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | -Word-by-word highlighting and PDF layout parsing capability are controlled by `COMPUTE_MODE` (server env), not an admin runtime flag. +Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag. Each row shows a source badge: diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 21171c5..4dff615 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -1,7 +1,7 @@ title: Compute Worker (NATS JetStream) --- -Use this guide for `COMPUTE_MODE=worker` deployments where heavy compute runs outside the Next.js app server. +Use this guide for worker-backed deployments where heavy compute runs outside the Next.js app server. ## Overview @@ -59,12 +59,11 @@ Advanced tuning (usually leave unset unless you need overrides): - `COMPUTE_NATS_REPLICAS=1` (JetStream stream + KV replicas; valid: `1`, `3`, `5`) - `COMPUTE_OP_STALE_MS=1800000` (stale op replacement window) -## App server environment variables (worker mode) +## App server environment variables Set on the Next.js app server: ```env -COMPUTE_MODE=worker # Local worker example: # COMPUTE_WORKER_URL=http://localhost:8081 # Cloud worker example (Railway): @@ -85,7 +84,7 @@ Model artifact overrides (`WHISPER_MODEL_BASE_URL`, `PDF_LAYOUT_MODEL_BASE_URL`) Set the same value on app + worker envs. -`COMPUTE_MODE=worker` has no local fallback. If worker is unavailable, affected requests fail. +There is no app-local compute fallback. If worker is unavailable, affected requests fail. ## Production notes @@ -184,12 +183,11 @@ Notes: - `COMPUTE_JOBS_STREAM_MAX_BYTES` and `COMPUTE_JOB_STATES_MAX_BYTES` are optional; defaults are `268435456` (256MiB) and `67108864` (64MiB). - `COMPUTE_NATS_REPLICAS` is optional; default is `1`. Valid values are `1`, `3`, `5`. -### 4. Configure the OpenReader app server (worker mode) +### 4. Configure the OpenReader app server Set these env vars on the app server: ```env -COMPUTE_MODE=worker COMPUTE_WORKER_URL=https:// COMPUTE_WORKER_TOKEN= ``` diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 22703cf..44da475 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -118,7 +118,7 @@ sudo apt install -y libreoffice No extra native Whisper CLI build step is required. -Set `COMPUTE_MODE=local` to enable built-in ONNX word alignment in-process. +Word-by-word highlighting and PDF layout parsing are worker-backed in current releases. If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env`. @@ -148,7 +148,6 @@ pnpm dev For app server worker mode, set: ```env -COMPUTE_MODE=worker COMPUTE_WORKER_URL=http://localhost:8081 COMPUTE_WORKER_TOKEN= ``` @@ -183,6 +182,13 @@ cp .env.example .env Then edit `.env`. +Required compute worker connectivity (all modes): + +```env +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= +``` + Use one of these `.env` mode templates: @@ -191,7 +197,6 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -COMPUTE_MODE=local # Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. # (Admin panel is unavailable without auth.) # API_BASE/API_KEY seed a shared default provider if you want shared mode. @@ -203,7 +208,6 @@ COMPUTE_MODE=local ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -218,7 +222,6 @@ AUTH_SECRET= # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -231,7 +234,6 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -COMPUTE_MODE=local USE_EMBEDDED_WEED_MINI=false S3_BUCKET=your-bucket S3_REGION=us-east-1 @@ -248,7 +250,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none -COMPUTE_MODE=worker COMPUTE_WORKER_URL=http://localhost:8081 COMPUTE_WORKER_TOKEN= USE_EMBEDDED_WEED_MINI=false diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index b587d1b..5218e81 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -8,7 +8,7 @@ This guide covers deploying OpenReader to Vercel with external Postgres and S3-c - Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. - Audiobook routes work on Node.js serverless functions using `ffmpeg-static`. -- Heavy compute features (Whisper alignment + PDF layout parsing) work through `COMPUTE_MODE=worker` with an external compute worker service. +- Heavy compute features (Whisper alignment + PDF layout parsing) run through an external compute worker service. - For worker setup details and worker-specific env vars, see [Compute Worker (NATS JetStream)](./compute-worker). :::warning DOCX Conversion Limitation @@ -37,10 +37,7 @@ BASE_URL=https://your-app.vercel.app AUTH_SECRET=... ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app -# Heavy compute (recommended on Vercel in v1) -# local = requires native binaries/models in-process (not recommended on Vercel) -# worker = external durable compute worker (recommended) -COMPUTE_MODE=worker +# Heavy compute (required on Vercel in current releases) COMPUTE_WORKER_URL=https:// COMPUTE_WORKER_TOKEN=... @@ -145,4 +142,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. Verify worker-backed word highlighting and PDF parsing in `COMPUTE_MODE=worker`. +4. Verify worker-backed word highlighting and PDF parsing. diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 4759768..6d00019 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -15,7 +15,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - 🧱 **Layout-aware PDF Parsing** - PP-DocLayoutV3 (ONNX) detects structured blocks with cross-page stitching and geometry-based highlighting for precise read-along sync and clean TTS segmentation - ⏱️ **Word-by-word Highlighting** via ONNX Whisper alignment - - No external dependencies — runs in-process (`COMPUTE_MODE=local`) or offloaded to a scalable NATS JetStream-backed worker (`COMPUTE_MODE=worker`) + - Powered by the external compute worker control plane (NATS JetStream-backed) - ⚡ **Segment-based TTS Playback** - Sentence-aware generation with cached audio segments, background preloading, and resumable playback across EPUB, PDF, TXT, MD, and DOCX - 🎯 **Multi-Provider TTS Support** diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 07d4ec1..a04de17 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,12 +53,11 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `COMPUTE_MODE` | Heavy compute backend | `local` | Select `local` (in-process) or `worker` (external worker service) | -| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required when `COMPUTE_MODE=worker`; base URL for external compute worker | +| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required; base URL for external compute worker | | `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Required bearer token for external compute worker auth | -| `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Local in-process compute concurrency cap; set worker service concurrency in compute-worker env/docs | -| `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (local + worker + worker client wait budget) | -| `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (local + worker + worker client wait budget) | +| `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Worker-side shared compute concurrency cap | +| `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (worker + worker client wait budget) | +| `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) | | `COMPUTE_OP_STALE_MS` | Heavy compute backend | `max(30m, 4x max compute timeout)` | Shared stale window for worker op replacement and app-side stale PDF parse-state healing | | `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | | `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped int8 downloads | @@ -354,40 +353,26 @@ Multiple library roots for server library import. ## Audio Tooling and Alignment -### COMPUTE_MODE - -Selects the backend for heavy compute features (ONNX word alignment + PDF layout parsing). - -- Default: `local` -- Supported in v1: - - `local`: run compute in-process on the app server - - `worker`: enqueue async jobs in an external durable compute worker (NATS JetStream + NATS KV) -- `worker` requires `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` -- `worker` assumes the external worker can directly reach shared object storage (S3-compatible endpoint) -- `worker` is not compatible with non-exposed embedded `weed mini` storage topologies -- Worker service env vars are documented in [Compute Worker (NATS JetStream)](../deploy/compute-worker) - ### COMPUTE_WORKER_URL Base URL for external compute worker mode. -- Used only when `COMPUTE_MODE=worker` +- Required - Example: `http://localhost:8081` ### COMPUTE_WORKER_TOKEN Bearer token for external compute worker auth. -- Used only when `COMPUTE_MODE=worker` +- Required - Must match worker service `COMPUTE_WORKER_TOKEN` ### COMPUTE_JOB_CONCURRENCY -In-process compute concurrency cap for app-server local compute mode. +Worker-side shared compute concurrency cap. - Default: `1` -- Applies when `COMPUTE_MODE=local` -- For `COMPUTE_MODE=worker`, set worker-side `COMPUTE_JOB_CONCURRENCY` in [Compute Worker (NATS JetStream)](../deploy/compute-worker) +- Set on the compute-worker service environment ### COMPUTE_WHISPER_TIMEOUT_MS @@ -395,8 +380,7 @@ Shared whisper alignment timeout budget in milliseconds. - Default: `30000` - Used by: - - Local compute whisper runtime (`COMPUTE_MODE=local`) - - Worker compute whisper runtime (`COMPUTE_MODE=worker`) + - Worker compute whisper runtime - App server worker-client wait budget (SSE wait timeout) ### COMPUTE_PDF_TIMEOUT_MS @@ -405,7 +389,6 @@ Shared PDF idle-timeout budget in milliseconds. - Default: `300000` (5 minutes) - Used by: - - Local compute PDF runtime (idle timeout) - Worker compute PDF runtime (idle timeout) - App server worker-client wait budget (SSE wait timeout) @@ -418,7 +401,7 @@ Shared stale window in milliseconds. - Worker op reuse/replacement guard (`/ops` opKey stale detection) - App-server PDF parse-state stale healing in `/api/documents/[id]/parsed*` - If a parse row is stuck in `pending`/`running` past this window, app routes mark it failed so retries/reparse can proceed. -- In `COMPUTE_MODE=worker`, keep this value aligned on both app-server and worker service envs. +- Keep this value aligned on both app-server and worker service envs. ### PDF_LAYOUT_MODEL_BASE_URL @@ -430,7 +413,7 @@ Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureMod - `PP-DocLayoutV3.onnx.data` - `config.json` - `preprocessor_config.json` -- In `COMPUTE_MODE=worker`, configure this on the worker service env (not only the app server env) +- Configure this on the worker service env (not only the app server env) ### WHISPER_MODEL_BASE_URL @@ -439,7 +422,7 @@ Optional base URL override for the built-in ONNX Whisper alignment model downloa - Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` - Default model variant: int8 (`encoder_model_int8.onnx`, `decoder_model_merged_int8.onnx`, `decoder_with_past_model_int8.onnx`) - The base URL must host all expected manifest files under the same relative paths. -- In `COMPUTE_MODE=worker`, configure this on the worker service env (not only the app server env) +- Configure this on the worker service env (not only the app server env) ### FFMPEG_BIN diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 5d1d538..aa20370 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -59,7 +59,7 @@ Monorepo packages under `compute/`: - Storage: AWS SDK v3 S3 client for reading/writing blobs - Logging: [Pino](https://getpino.io/) - Validation: [Zod](https://zod.dev/) -- Compute mode is controlled by `COMPUTE_MODE` env var: `local` (in-process) or `worker` (remote queue via HTTP + NATS) +- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS) ## Tooling and testing From 0e6f966e3aae814289b5f215554a21bb93f07336 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 16:38:22 -0600 Subject: [PATCH 103/137] hard-cut batch C: worker projection ownership finalized From 943e5478361814fab1a442352c1b72aaeae5047b Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 16:39:40 -0600 Subject: [PATCH 104/137] test: fix JetStream mock typing for tsc --- ...ompute-worker-control-plane-jetstream.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/compute-worker-control-plane-jetstream.spec.ts b/tests/unit/compute-worker-control-plane-jetstream.spec.ts index 641b78e..fb3f3e3 100644 --- a/tests/unit/compute-worker-control-plane-jetstream.spec.ts +++ b/tests/unit/compute-worker-control-plane-jetstream.spec.ts @@ -70,11 +70,11 @@ class FakeJetStream { }, }; - async publish(subject: string, data: Uint8Array): Promise<{ seq: number }> { + async publish(subject: string, data: Uint8Array): Promise<{ seq: number; stream: string; duplicate: boolean }> { this.seq += 1; const payload = JSON.parse(new TextDecoder().decode(data)) as unknown; this.published.push({ subject, payload, seq: this.seq }); - return { seq: this.seq }; + return { seq: this.seq, stream: 'fake', duplicate: false }; } } @@ -126,18 +126,18 @@ test.describe('worker jetstream control-plane adapters', () => { test('queue and event adapters publish expected JetStream subjects', async () => { const js = new FakeJetStream(); const queue = new JetStreamOperationQueue({ - getJs: async () => js, + getJs: async () => js as any, whisperSubject: 'jobs.whisper', layoutSubject: 'jobs.layout', }); const events = new JetStreamOperationEventStream({ - getJs: async () => js, + getJs: async () => js as any, getJsm: async () => ({ consumers: { add: async () => ({ name: 'noop' }), delete: async () => true, }, - }), + }) as any, eventsStreamName: 'compute_events', }); @@ -170,17 +170,17 @@ test.describe('worker jetstream control-plane adapters', () => { const store = new JetStreamOperationStateStore({ getKv: async () => kv }); const events = new JetStreamOperationEventStream({ - getJs: async () => js, + getJs: async () => js as any, getJsm: async () => ({ consumers: { add: async () => ({ name: 'noop' }), delete: async () => true, }, - }), + }) as any, eventsStreamName: 'compute_events', }); const queue = new JetStreamOperationQueue({ - getJs: async () => js, + getJs: async () => js as any, whisperSubject: 'jobs.whisper', layoutSubject: 'jobs.layout', }); From a073fb63a3da1e58f77c16b66ea55f3032b5dca1 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 17:00:41 -0600 Subject: [PATCH 105/137] runtime: default embedded worker startup when worker url is unset --- .env.example | 4 +++- scripts/openreader-entrypoint.mjs | 16 ++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 0171ff3..34f3147 100644 --- a/.env.example +++ b/.env.example @@ -85,7 +85,9 @@ IMPORT_LIBRARY_DIRS= # COMPUTE_WORKER_URL=http://localhost:8081 # COMPUTE_WORKER_TOKEN=local-compute-token # Optional embedded worker stack controls (used by scripts/openreader-entrypoint.mjs): -# START_EMBEDDED_COMPUTE_WORKER=true +# If COMPUTE_WORKER_URL is unset, entrypoint defaults to starting embedded worker+NATS. +# Set START_EMBEDDED_COMPUTE_WORKER=false to force external worker URL/token config. +# START_EMBEDDED_COMPUTE_WORKER= # EMBEDDED_COMPUTE_WORKER_PORT=8081 # EMBEDDED_NATS_PORT=4222 # EMBEDDED_NATS_MONITOR_PORT=8222 diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 000719e..164cfd8 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -445,17 +445,21 @@ async function main() { const embeddedWorkerPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_COMPUTE_WORKER_PORT, '8081'), 10); const embeddedNatsPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_PORT, '4222'), 10); const embeddedNatsMonitorPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_MONITOR_PORT, '8222'), 10); - const shouldStartEmbeddedWorker = resolveBooleanEnv( - runtimeEnv, - 'START_EMBEDDED_COMPUTE_WORKER', - isRunningInDocker() && !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()), + const embeddedWorkerEnvRaw = runtimeEnv.START_EMBEDDED_COMPUTE_WORKER; + let shouldStartEmbeddedWorker = isTrue( + embeddedWorkerEnvRaw, + !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()), ); - if (shouldStartEmbeddedWorker) { - if (!hasNatsBinary()) { + if (shouldStartEmbeddedWorker && !hasNatsBinary()) { + if (embeddedWorkerEnvRaw && isTrue(embeddedWorkerEnvRaw, true)) { throw new Error('START_EMBEDDED_COMPUTE_WORKER=true but `nats-server` binary is not available in PATH.'); } + shouldStartEmbeddedWorker = false; + console.warn('`nats-server` binary not found; skipping embedded compute worker startup.'); + } + if (shouldStartEmbeddedWorker) { runtimeEnv.NATS_URL = withDefault(runtimeEnv.NATS_URL, `nats://127.0.0.1:${embeddedNatsPort}`); runtimeEnv.COMPUTE_WORKER_URL = withDefault(runtimeEnv.COMPUTE_WORKER_URL, `http://127.0.0.1:${embeddedWorkerPort}`); runtimeEnv.COMPUTE_WORKER_TOKEN = withDefault( From e3ac1b1dac77f7b6cc64aa4ce4a6db66c0498fd7 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 17:16:38 -0600 Subject: [PATCH 106/137] docs/env: clarify embedded vs external worker env ownership --- .env.example | 2 + compute/worker/.env.example | 4 ++ docs-site/docs/deploy/compute-worker.md | 22 +++++-- docs-site/docs/deploy/local-development.md | 24 ++++++-- .../docs/reference/environment-variables.md | 60 ++++++++++++++++--- docs-site/docs/reference/stack.md | 2 +- 6 files changed, 96 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 34f3147..1485412 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,8 @@ IMPORT_LIBRARY_DIRS= # Heavy compute is always worker-backed. # App server calls compute-worker over HTTP for ONNX whisper alignment + PDF layout parsing. +# Embedded/local (`pnpm dev` / `pnpm start` without COMPUTE_WORKER_URL) uses this same file. +# `compute/worker/.env*` is only for standalone external worker deployments. # In single-container self-host setups, run compute-worker + NATS alongside the app and # point COMPUTE_WORKER_URL at that internal worker endpoint. # Required: diff --git a/compute/worker/.env.example b/compute/worker/.env.example index ae593dd..f6188a6 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -1,3 +1,7 @@ +# Standalone compute-worker env. +# Used only when running compute-worker as a separate service/container. +# Not used by app embedded worker startup via `pnpm dev` / `pnpm start`. + # Compute worker bind # Platform note: # - Local/manual: keep PORT=8081 diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 4dff615..5f027ee 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -1,7 +1,8 @@ title: Compute Worker (NATS JetStream) --- -Use this guide for worker-backed deployments where heavy compute runs outside the Next.js app server. +Use this guide when compute-worker runs as a standalone service outside the Next.js app server. +For embedded/local startup (`pnpm dev` / `pnpm start` without `COMPUTE_WORKER_URL`), use root `.env` instead. ## Overview @@ -30,9 +31,12 @@ Required: - `S3_SECRET_ACCESS_KEY` > [!IMPORTANT] -> **S3 credentials cannot be left blank/empty** when running in worker mode. -> While the main Next.js server can generate random, dynamic S3 keys on-the-fly when `USE_EMBEDDED_WEED_MINI=true` and `S3_*` vars are blank, the compute worker runs in a separate process and cannot connect to SeaweedFS using those dynamically generated keys. -> To use the compute worker with the embedded SeaweedFS, you **must configure identical, stable S3 credentials** (e.g. `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`) in both the root `.env` and the compute worker `.env` files. +> This file (`compute/worker/.env*`) is only for standalone worker deployments. +> In embedded/local startup, app entrypoint spawns worker with the already-resolved root `.env` values. +> For standalone worker deployments, keep shared app/worker values aligned: +> - `COMPUTE_WORKER_TOKEN` +> - shared object storage settings (`S3_*`) +> - shared timeout/stale settings (`COMPUTE_WHISPER_TIMEOUT_MS`, `COMPUTE_PDF_TIMEOUT_MS`, `COMPUTE_OP_STALE_MS`) Common optional: @@ -86,6 +90,16 @@ Set the same value on app + worker envs. There is no app-local compute fallback. If worker is unavailable, affected requests fail. +## Config ownership summary + +- Embedded/local startup (`pnpm dev` / `pnpm start`, no `COMPUTE_WORKER_URL`): + - Configure root `.env` only. + - `compute/worker/.env*` is ignored. +- Standalone external worker service: + - Configure app root `.env` with `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN`. + - Configure worker service env (`compute/worker/.env*` or platform env). + - Keep shared values aligned (`COMPUTE_WORKER_TOKEN`, `S3_*`, timeout/stale values). + ## Production notes - Worker mode assumes shared object storage is reachable by both app server and worker. diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 44da475..01dec45 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -127,7 +127,8 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i
External compute worker dev stack (optional) -Use this when you want durable compute with NATS JetStream + KV while keeping Next.js on native host `pnpm dev`. +Use this only when you intentionally run compute-worker as a separate service. +Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`. Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker). Start only NATS + compute-worker via compose watch: @@ -137,7 +138,7 @@ docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.y # or: pnpm compute:dev:watch ``` -`compute/worker/.env.example` contains a starter config. Copy it to `compute/worker/.env` and adjust values for your environment. +`compute/worker/.env.example` contains a starter config for standalone worker service deployments. Run the main app separately on the host: @@ -145,7 +146,7 @@ Run the main app separately on the host: pnpm dev ``` -For app server worker mode, set: +For app -> external worker routing, set in root `.env`: ```env COMPUTE_WORKER_URL=http://localhost:8081 @@ -153,7 +154,7 @@ COMPUTE_WORKER_TOKEN= ``` Worker mode requires worker-reachable shared object storage (S3-compatible endpoint). -Non-exposed embedded `weed mini` is not a supported topology for external worker mode. +For external worker mode, object storage must be shared/reachable by both app and worker services.
@@ -182,7 +183,15 @@ cp .env.example .env Then edit `.env`. -Required compute worker connectivity (all modes): +Default embedded worker flow (no external worker URL): + +```env +# Leave COMPUTE_WORKER_URL unset. +# Entry point auto-starts embedded worker+NATS when available. +START_EMBEDDED_COMPUTE_WORKER= +``` + +External worker flow: ```env COMPUTE_WORKER_URL=http://localhost:8081 @@ -245,7 +254,7 @@ S3_SECRET_ACCESS_KEY=your-secret-key ``` - + ```env API_BASE=http://host.docker.internal:8880/v1 @@ -292,6 +301,9 @@ Learn about migration behavior and commands in [Migrations](../configure/migrati pnpm dev ``` +If you use embedded worker startup (no `COMPUTE_WORKER_URL`) and the host is missing `nats-server`, +install `nats-server` locally or switch to external worker mode. + diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index a04de17..190dc93 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,8 +53,14 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Required; base URL for external compute worker | -| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Required bearer token for external compute worker auth | +| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup | +| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset (auto-generated in embedded startup) | Required for standalone external compute worker auth; must match worker | +| `START_EMBEDDED_COMPUTE_WORKER` | Heavy compute backend | auto (`true` when `COMPUTE_WORKER_URL` unset) | Set `false` to disable embedded worker startup and require external worker URL/token | +| `EMBEDDED_COMPUTE_WORKER_PORT` | Heavy compute backend | `8081` | Override embedded worker bind port | +| `EMBEDDED_NATS_PORT` | Heavy compute backend | `4222` | Override embedded NATS client port | +| `EMBEDDED_NATS_MONITOR_PORT` | Heavy compute backend | `8222` | Override embedded NATS monitor port | +| `EMBEDDED_NATS_STORE_DIR` | Heavy compute backend | `docstore/nats/jetstream` | Override embedded JetStream storage directory | +| `NATS_URL` | Heavy compute backend | `nats://127.0.0.1:4222` in embedded startup | Optional override for embedded startup or required on standalone worker service | | `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Worker-side shared compute concurrency cap | | `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (worker + worker client wait budget) | | `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) | @@ -355,17 +361,57 @@ Multiple library roots for server library import. ### COMPUTE_WORKER_URL -Base URL for external compute worker mode. +Base URL for standalone external compute worker mode. -- Required +- Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS. +- Required only when using a standalone external worker service. - Example: `http://localhost:8081` ### COMPUTE_WORKER_TOKEN -Bearer token for external compute worker auth. +Bearer token for compute-worker auth. -- Required -- Must match worker service `COMPUTE_WORKER_TOKEN` +- Required for standalone external worker service mode. +- Must match worker service `COMPUTE_WORKER_TOKEN`. +- In embedded startup, entrypoint auto-generates one if unset. + +### START_EMBEDDED_COMPUTE_WORKER + +Controls whether entrypoint auto-starts embedded compute-worker + NATS. + +- Default behavior: enabled when `COMPUTE_WORKER_URL` is unset +- Set `false` to disable embedded startup and require external worker URL/token + +### EMBEDDED_COMPUTE_WORKER_PORT + +Embedded compute-worker HTTP port. + +- Default: `8081` + +### EMBEDDED_NATS_PORT + +Embedded NATS client port. + +- Default: `4222` + +### EMBEDDED_NATS_MONITOR_PORT + +Embedded NATS monitor (`/healthz`) port. + +- Default: `8222` + +### EMBEDDED_NATS_STORE_DIR + +Embedded JetStream storage directory. + +- Default: `docstore/nats/jetstream` + +### NATS_URL + +NATS connection URL used by compute worker runtime. + +- Embedded startup default: `nats://127.0.0.1:4222` +- Standalone worker service: set in worker service env (`compute/worker/.env*` or platform env) ### COMPUTE_JOB_CONCURRENCY diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index aa20370..49f5f09 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -47,7 +47,7 @@ title: Stack Monorepo packages under `compute/`: -- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared between local and worker modes +- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts - ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers` - Whisper alignment: `onnx-community/whisper-base_timestamped` (int8) for word-level timestamps - PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing From 02979a98af2fb9c70449cca9d53c4239503c6d94 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 17:37:18 -0600 Subject: [PATCH 107/137] refactor(config): remove START_EMBEDDED_COMPUTE_WORKER env and enforce nats-server for embedded mode Eliminates the START_EMBEDDED_COMPUTE_WORKER environment variable from configuration, documentation, and entrypoint logic. Embedded compute worker startup is now strictly determined by the absence of COMPUTE_WORKER_URL. If embedded mode is triggered, the presence of the nats-server binary is mandatory; otherwise, an error is thrown. Documentation and example env files have been updated to reflect this streamlined behavior and clarify requirements for both embedded and external compute worker setups. --- .env.example | 22 ++++-------- compute/worker/.env.example | 20 +++++------ docs-site/docs/deploy/local-development.md | 36 ++++++++++++++++++- .../docs/reference/environment-variables.md | 10 ++---- scripts/openreader-entrypoint.mjs | 15 +++----- 5 files changed, 56 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 1485412..36cc589 100644 --- a/.env.example +++ b/.env.example @@ -77,33 +77,23 @@ RUN_FS_MIGRATIONS= IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= -# Heavy compute is always worker-backed. -# App server calls compute-worker over HTTP for ONNX whisper alignment + PDF layout parsing. -# Embedded/local (`pnpm dev` / `pnpm start` without COMPUTE_WORKER_URL) uses this same file. -# `compute/worker/.env*` is only for standalone external worker deployments. -# In single-container self-host setups, run compute-worker + NATS alongside the app and -# point COMPUTE_WORKER_URL at that internal worker endpoint. -# Required: +# Compute +# Embedded/local (default): leave COMPUTE_WORKER_URL empty. +# External worker: set COMPUTE_WORKER_URL + COMPUTE_WORKER_TOKEN. +# Details: docs/deploy/compute-worker # COMPUTE_WORKER_URL=http://localhost:8081 # COMPUTE_WORKER_TOKEN=local-compute-token -# Optional embedded worker stack controls (used by scripts/openreader-entrypoint.mjs): -# If COMPUTE_WORKER_URL is unset, entrypoint defaults to starting embedded worker+NATS. -# Set START_EMBEDDED_COMPUTE_WORKER=false to force external worker URL/token config. -# START_EMBEDDED_COMPUTE_WORKER= +# Optional embedded startup controls: # EMBEDDED_COMPUTE_WORKER_PORT=8081 # EMBEDDED_NATS_PORT=4222 # EMBEDDED_NATS_MONITOR_PORT=8222 # EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream # NATS_URL=nats://127.0.0.1:4222 -# Optional shared compute timeouts used by: -# - worker compute runtime -# - worker client wait budgets -# Worker-side concurrency is configured in compute-worker service via `COMPUTE_JOB_CONCURRENCY`. +# Optional shared compute tuning: # COMPUTE_JOB_CONCURRENCY=1 # COMPUTE_WHISPER_TIMEOUT_MS=30000 # COMPUTE_PDF_TIMEOUT_MS=300000 # COMPUTE_OP_STALE_MS=1800000 -# Worker mode requires worker-reachable shared object storage. # Optional Whisper ONNX base URL override (must contain all expected files) # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main diff --git a/compute/worker/.env.example b/compute/worker/.env.example index f6188a6..3c38f5d 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -1,6 +1,6 @@ -# Standalone compute-worker env. -# Used only when running compute-worker as a separate service/container. -# Not used by app embedded worker startup via `pnpm dev` / `pnpm start`. +# External compute-worker service env only. +# Keep COMPUTE_WORKER_TOKEN and S3_* aligned with app env. +# Details: docs/deploy/compute-worker # Compute worker bind # Platform note: @@ -11,7 +11,7 @@ # COMPUTE_LOG_FORMAT=pretty # COMPUTE_LOG_LEVEL=info -# App <-> worker auth +# Must match app env when app uses COMPUTE_WORKER_URL COMPUTE_WORKER_TOKEN=local-compute-token # NATS/JetStream @@ -32,21 +32,17 @@ S3_SECRET_ACCESS_KEY=devsecret S3_ENDPOINT=http://host.docker.internal:8333 S3_FORCE_PATH_STYLE=true -# Queue + execution tuning +# Optional tuning # COMPUTE_PREWARM_MODELS=true # COMPUTE_JOB_CONCURRENCY=1 # COMPUTE_WHISPER_TIMEOUT_MS=30000 # COMPUTE_PDF_TIMEOUT_MS=300000 -# Optional Whisper ONNX base URL override (must contain all expected files) -# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main -# Optional PDF layout ONNX base URL override (must contain all expected files) -# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main # COMPUTE_PDF_JOB_ATTEMPTS=1 # COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 -# JetStream stream size limit for op progress events (replay for SSE reconnect) # COMPUTE_EVENTS_STREAM_MAX_BYTES=134217728 # COMPUTE_JOB_STATES_MAX_BYTES=67108864 # COMPUTE_NATS_REPLICAS=1 -# Optional stale window for reusing in-flight opKey entries before forcing a new attempt -# Default is max(30m, 4x max compute timeout); running jobs also refresh heartbeat every 5s # COMPUTE_OP_STALE_MS=1800000 +# Optional model mirrors +# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main +# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 01dec45..14ca0a4 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -89,6 +89,41 @@ OpenReader currently pins `4.18` in CI and Docker builds while `4.19` compatibil +
+NATS Server nats-server (required for embedded compute mode) + +If `COMPUTE_WORKER_URL` is unset, startup launches embedded compute worker + NATS, so `nats-server` must be available on host PATH. + +If you always use an external worker (`COMPUTE_WORKER_URL` set), this is not required. + + + + +```bash +brew install nats-server +nats-server -v +``` + + + + +```bash +# Linux amd64 example +mkdir -p "$HOME/.local/bin" +curl -fsSL -o /tmp/nats-server.zip \ + https://github.com/nats-io/nats-server/releases/latest/download/nats-server-v2.12.1-linux-amd64.zip +unzip -j /tmp/nats-server.zip '*/nats-server' -d /tmp +install -m 0755 /tmp/nats-server "$HOME/.local/bin/nats-server" +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +export PATH="$HOME/.local/bin:$PATH" +nats-server -v +``` + + + + +
+
LibreOffice (optional, for DOCX conversion) @@ -188,7 +223,6 @@ Default embedded worker flow (no external worker URL): ```env # Leave COMPUTE_WORKER_URL unset. # Entry point auto-starts embedded worker+NATS when available. -START_EMBEDDED_COMPUTE_WORKER= ``` External worker flow: diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 190dc93..005ef32 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -55,7 +55,6 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | | `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup | | `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset (auto-generated in embedded startup) | Required for standalone external compute worker auth; must match worker | -| `START_EMBEDDED_COMPUTE_WORKER` | Heavy compute backend | auto (`true` when `COMPUTE_WORKER_URL` unset) | Set `false` to disable embedded worker startup and require external worker URL/token | | `EMBEDDED_COMPUTE_WORKER_PORT` | Heavy compute backend | `8081` | Override embedded worker bind port | | `EMBEDDED_NATS_PORT` | Heavy compute backend | `4222` | Override embedded NATS client port | | `EMBEDDED_NATS_MONITOR_PORT` | Heavy compute backend | `8222` | Override embedded NATS monitor port | @@ -364,6 +363,7 @@ Multiple library roots for server library import. Base URL for standalone external compute worker mode. - Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS. +- Embedded startup requires `nats-server` available on host PATH. - Required only when using a standalone external worker service. - Example: `http://localhost:8081` @@ -375,13 +375,6 @@ Bearer token for compute-worker auth. - Must match worker service `COMPUTE_WORKER_TOKEN`. - In embedded startup, entrypoint auto-generates one if unset. -### START_EMBEDDED_COMPUTE_WORKER - -Controls whether entrypoint auto-starts embedded compute-worker + NATS. - -- Default behavior: enabled when `COMPUTE_WORKER_URL` is unset -- Set `false` to disable embedded startup and require external worker URL/token - ### EMBEDDED_COMPUTE_WORKER_PORT Embedded compute-worker HTTP port. @@ -412,6 +405,7 @@ NATS connection URL used by compute worker runtime. - Embedded startup default: `nats://127.0.0.1:4222` - Standalone worker service: set in worker service env (`compute/worker/.env*` or platform env) +- For embedded startup, this is optional; startup supplies the default value. ### COMPUTE_JOB_CONCURRENCY diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 164cfd8..f21feb7 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -445,18 +445,13 @@ async function main() { const embeddedWorkerPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_COMPUTE_WORKER_PORT, '8081'), 10); const embeddedNatsPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_PORT, '4222'), 10); const embeddedNatsMonitorPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_MONITOR_PORT, '8222'), 10); - const embeddedWorkerEnvRaw = runtimeEnv.START_EMBEDDED_COMPUTE_WORKER; - let shouldStartEmbeddedWorker = isTrue( - embeddedWorkerEnvRaw, - !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()), - ); + const shouldStartEmbeddedWorker = !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()); if (shouldStartEmbeddedWorker && !hasNatsBinary()) { - if (embeddedWorkerEnvRaw && isTrue(embeddedWorkerEnvRaw, true)) { - throw new Error('START_EMBEDDED_COMPUTE_WORKER=true but `nats-server` binary is not available in PATH.'); - } - shouldStartEmbeddedWorker = false; - console.warn('`nats-server` binary not found; skipping embedded compute worker startup.'); + throw new Error( + '`nats-server` binary is required when COMPUTE_WORKER_URL is unset. ' + + 'Install nats-server or set COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN for an external worker.', + ); } if (shouldStartEmbeddedWorker) { From e5ee621288a4e36bcebc781f1d7c3df0e77b94ce Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:05:45 -0600 Subject: [PATCH 108/137] refactor(docstore): improve docstore directory resolution for monorepo setups Refactored docstore directory logic to dynamically locate the monorepo root by searching for a pnpm-workspace.yaml marker. If found, the docstore directory is anchored at the monorepo root; otherwise, it defaults to the current working directory. This enhances consistency when running in different environments. Also updated process shutdown and SeaweedFS launch arguments in the entrypoint script for improved reliability and log filtering. --- compute/core/src/platform/docstore.ts | 20 +++++++++++++++++++- scripts/openreader-entrypoint.mjs | 10 +++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/compute/core/src/platform/docstore.ts b/compute/core/src/platform/docstore.ts index 7d7dd6b..6681f37 100644 --- a/compute/core/src/platform/docstore.ts +++ b/compute/core/src/platform/docstore.ts @@ -1,6 +1,24 @@ +import fs from 'fs'; import path from 'path'; -export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +function findMonorepoRoot(startDir: string): string | null { + let current = path.resolve(startDir); + for (;;) { + const marker = path.join(current, 'pnpm-workspace.yaml'); + if (fs.existsSync(marker)) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function resolveDocstoreDir(): string { + const repoRoot = findMonorepoRoot(process.cwd()); + if (repoRoot) return path.join(repoRoot, 'docstore'); + return path.join(process.cwd(), 'docstore'); +} + +export const DOCSTORE_DIR = resolveDocstoreDir(); export function getDocstoreDir(): string { return DOCSTORE_DIR; diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index f21feb7..9223bf6 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -345,7 +345,7 @@ async function main() { shutdownPromise = (async () => { await Promise.all([ terminateChild(appProc, signal, 4000), - terminateChild(workerProc, 'SIGTERM', 4000, true), + terminateChild(workerProc, 'SIGTERM', 4000), terminateChild(natsProc, 'SIGTERM', 4000), terminateChild(weedProc, 'SIGTERM', 4000), ]); @@ -397,7 +397,12 @@ async function main() { const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20; const launchWeed = (endpointUrl) => { const parsedEndpoint = parseS3Endpoint(endpointUrl); - const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`]; + const weedArgs = [ + '-alsologtostderr=false', + '-stderrthreshold=WARNING', + 'mini', + `-dir=${runtimeEnv.WEED_MINI_DIR}`, + ]; weedArgs.push(`-s3.port=${parsedEndpoint.port}`); if (runningInDocker) { weedArgs.push('-ip.bind=0.0.0.0'); @@ -512,7 +517,6 @@ async function main() { env: workerEnv, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', - detached: process.platform !== 'win32', }, ); stopWorkerStdoutForward = forwardChildStream(workerProc.stdout, process.stdout); From bfea95aa35bd0d5b10a19105e8c9494702f3f560 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:05:57 -0600 Subject: [PATCH 109/137] fix(entrypoint): ensure fatal shutdown on critical embedded service exit Add robust fatal shutdown handling for embedded services (weed mini, nats-server, compute-worker) to ensure all services are terminated if any critical process exits unexpectedly. Introduce flags to prevent duplicate shutdowns and improve error reporting for better reliability in process management. --- scripts/openreader-entrypoint.mjs | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 9223bf6..7abebf5 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -326,6 +326,8 @@ async function main() { let workerExitPromise = Promise.resolve(); let appProc = null; let shutdownPromise = null; + let isShuttingDown = false; + let fatalExitScheduled = false; let stopWeedStdoutForward = () => { }; let stopWeedStderrForward = () => { }; let stopNatsStdoutForward = () => { }; @@ -340,8 +342,22 @@ async function main() { process.exit(code); }; + const scheduleFatalShutdown = (serviceName, code, signal, detail) => { + if (fatalExitScheduled || isShuttingDown || didExit) return; + fatalExitScheduled = true; + const codeLabel = typeof code === 'number' ? String(code) : 'null'; + const signalLabel = signal ?? 'null'; + const suffix = detail ? ` (${detail})` : ''; + console.error( + `Critical service "${serviceName}" exited unexpectedly: code=${codeLabel} signal=${signalLabel}${suffix}. ` + + 'Shutting down all services.', + ); + void shutdown('SIGTERM').finally(() => exitOnce(typeof code === 'number' && code !== 0 ? code : 1)); + }; + const shutdown = async (signal = 'SIGTERM') => { if (shutdownPromise) return shutdownPromise; + isShuttingDown = true; shutdownPromise = (async () => { await Promise.all([ terminateChild(appProc, signal, 4000), @@ -417,13 +433,13 @@ async function main() { weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined); weedProc.on('exit', (code, signal) => { + if (isShuttingDown) return; if (typeof code === 'number' && code !== 0) { console.error(`Embedded weed mini exited with code ${code}.`); - return; - } - if (signal) { + } else if (signal) { console.error(`Embedded weed mini exited due to signal ${signal}.`); } + scheduleFatalShutdown('weed mini', code, signal, 'embedded storage service'); }); }; @@ -491,16 +507,17 @@ async function main() { stopNatsStderrForward = forwardChildStream(natsProc.stderr, process.stderr); natsExitPromise = once(natsProc, 'exit').then(() => undefined).catch(() => undefined); natsProc.on('exit', (code, signal) => { + if (isShuttingDown) return; if (typeof code === 'number' && code !== 0) { console.error(`Embedded nats-server exited with code ${code}.`); - return; - } - if (signal) { + } else if (signal) { console.error(`Embedded nats-server exited due to signal ${signal}.`); } + scheduleFatalShutdown('nats-server', code, signal, 'embedded queue service'); }); natsProc.on('error', (error) => { console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`); + scheduleFatalShutdown('nats-server', null, null, 'failed to start'); }); await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20); console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`); @@ -523,16 +540,17 @@ async function main() { stopWorkerStderrForward = forwardChildStream(workerProc.stderr, process.stderr); workerExitPromise = once(workerProc, 'exit').then(() => undefined).catch(() => undefined); workerProc.on('exit', (code, signal) => { + if (isShuttingDown) return; if (typeof code === 'number' && code !== 0) { console.error(`Embedded compute-worker exited with code ${code}.`); - return; - } - if (signal) { + } else if (signal) { console.error(`Embedded compute-worker exited due to signal ${signal}.`); } + scheduleFatalShutdown('compute-worker', code, signal, 'embedded compute service'); }); workerProc.on('error', (error) => { console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`); + scheduleFatalShutdown('compute-worker', null, null, 'failed to start'); }); await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30); console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`); From 6f68c169b664e57248d4277a238d030ef611d597 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:38:50 -0600 Subject: [PATCH 110/137] feat(compute): integrate worker operation state polling for document parsing Add worker operation state polling to document parse routes, enabling real-time status and progress updates from the ONNX layout worker. Introduce `fetchWorkerOperationState` utility and status mapping logic to synchronize parse status with worker job state, improving accuracy for pending, running, and failed operations. --- .../api/documents/[id]/parsed/events/route.ts | 13 ++++- src/app/api/documents/[id]/parsed/route.ts | 50 +++++++++++++++++-- src/lib/server/compute/worker-op-state.ts | 43 ++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/lib/server/compute/worker-op-state.ts diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 9660453..077b7e0 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; +import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; @@ -76,12 +77,22 @@ async function toSnapshotState(row: ParseRow): Promise { state: parseDocumentParseState(row.parseState), }); const parseStatus = normalizeParseStatus(state.status); + const opId = typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null; + if (opId && parseStatus !== 'ready') { + const workerState = await fetchWorkerOperationState(opId); + if (workerState && workerState.opId === opId) { + return { + snapshot: snapshotFromWorkerState(workerState), + opId, + }; + } + } return { snapshot: { parseStatus, parseProgress: state.progress ?? null, }, - opId: typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null, + opId, }; } diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 57bc25f..5bcb2dc 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -4,6 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; +import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { getParsedDocumentBlob, getParsedDocumentBlobByKey, @@ -20,6 +21,7 @@ import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state- import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; export const dynamic = 'force-dynamic'; @@ -35,6 +37,21 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); } +function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']) { + switch (status) { + case 'queued': + return 'pending' as const; + case 'running': + return 'running' as const; + case 'succeeded': + return 'ready' as const; + case 'failed': + return 'failed' as const; + default: + return 'pending' as const; + } +} + export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -80,7 +97,20 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string userId: row.userId, state, }); - const effectiveStatus = normalizeParseStatus(state.status); + let effectiveStatus = normalizeParseStatus(state.status); + let effectiveProgress = state.progress ?? null; + const opId = typeof state.opId === 'string' ? state.opId.trim() : ''; + if (opId && effectiveStatus !== 'ready') { + const workerState = await fetchWorkerOperationState(opId); + if (workerState && workerState.opId === opId) { + const workerStatus = mapWorkerStatusToParseStatus(workerState.status); + // Keep DB/blob as source of truth for "ready"; prefer worker only for active/failed states. + if (workerStatus === 'pending' || workerStatus === 'running' || workerStatus === 'failed') { + effectiveStatus = workerStatus; + effectiveProgress = workerStatus === 'running' ? (workerState.progress ?? null) : null; + } + } + } if (effectiveStatus === 'failed' && retryFailed) { await db @@ -104,7 +134,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string if (effectiveStatus !== 'ready') { return NextResponse.json({ parseStatus: effectiveStatus, - parseProgress: state.progress ?? null, + parseProgress: effectiveProgress, }, { status: 202 }); } @@ -190,7 +220,19 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string userId: row.userId, state, }); - const effectiveStatus = normalizeParseStatus(state.status); + let effectiveStatus = normalizeParseStatus(state.status); + let effectiveProgress = state.progress ?? null; + const opId = typeof state.opId === 'string' ? state.opId.trim() : ''; + if (opId && effectiveStatus !== 'ready') { + const workerState = await fetchWorkerOperationState(opId); + if (workerState && workerState.opId === opId) { + const workerStatus = mapWorkerStatusToParseStatus(workerState.status); + if (workerStatus === 'pending' || workerStatus === 'running' || workerStatus === 'failed') { + effectiveStatus = workerStatus; + effectiveProgress = workerStatus === 'running' ? (workerState.progress ?? null) : null; + } + } + } if (effectiveStatus !== 'running') { await db @@ -215,7 +257,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string return NextResponse.json( { parseStatus: effectiveStatus === 'running' ? 'running' : 'pending', - parseProgress: effectiveStatus === 'running' ? (state.progress ?? null) : null, + parseProgress: effectiveStatus === 'running' ? effectiveProgress : null, }, { status: 202 }, ); diff --git a/src/lib/server/compute/worker-op-state.ts b/src/lib/server/compute/worker-op-state.ts new file mode 100644 index 0000000..fcf4ce8 --- /dev/null +++ b/src/lib/server/compute/worker-op-state.ts @@ -0,0 +1,43 @@ +import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; +import type { WorkerOperationState } from '@openreader/compute-core/api-contracts'; + +const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500; + +export async function fetchWorkerOperationState( + opId: string | null | undefined, +): Promise | null> { + const normalized = opId?.trim(); + if (!normalized) return null; + + let cfg: { baseUrl: string; token: string }; + try { + cfg = getWorkerClientConfigFromEnv(); + } catch { + return null; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), WORKER_OP_REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(`${cfg.baseUrl}/ops/${encodeURIComponent(normalized)}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${cfg.token}`, + Accept: 'application/json', + }, + cache: 'no-store', + signal: controller.signal, + }); + + if (!res.ok) return null; + const parsed = await res.json() as WorkerOperationState; + if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) return null; + return parsed; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + From e6e029cf8a6c2a854dda76f92a0b20890dabdff8 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:43:05 -0600 Subject: [PATCH 111/137] cleanup compute core/server dead paths --- compute/core/src/control-plane/types.ts | 4 -- compute/core/src/platform/docstore.ts | 4 -- src/lib/server/compute/concurrency-limiter.ts | 41 ------------ src/lib/server/compute/index.ts | 6 +- src/lib/server/compute/types.ts | 2 - .../unit/compute-concurrency-limiter.spec.ts | 65 ------------------- 6 files changed, 1 insertion(+), 121 deletions(-) delete mode 100644 src/lib/server/compute/concurrency-limiter.ts delete mode 100644 tests/unit/compute-concurrency-limiter.spec.ts diff --git a/compute/core/src/control-plane/types.ts b/compute/core/src/control-plane/types.ts index 01ea1fc..79fd16c 100644 --- a/compute/core/src/control-plane/types.ts +++ b/compute/core/src/control-plane/types.ts @@ -1,11 +1,9 @@ import type { WorkerOperationEvent, WorkerOperationKind, - WorkerOperationRequest, WorkerOperationState, } from '../api-contracts'; -export type OperationRequest = WorkerOperationRequest; export type OperationState = WorkerOperationState; export type OperationEvent = WorkerOperationEvent; @@ -63,5 +61,3 @@ export interface OperationLifecycleConfig { opStaleMs: number; maxCasRetries?: number; } - -export type OperationTransitionStatus = 'queued' | 'running' | 'succeeded' | 'failed'; diff --git a/compute/core/src/platform/docstore.ts b/compute/core/src/platform/docstore.ts index 6681f37..0b62401 100644 --- a/compute/core/src/platform/docstore.ts +++ b/compute/core/src/platform/docstore.ts @@ -19,7 +19,3 @@ function resolveDocstoreDir(): string { } export const DOCSTORE_DIR = resolveDocstoreDir(); - -export function getDocstoreDir(): string { - return DOCSTORE_DIR; -} diff --git a/src/lib/server/compute/concurrency-limiter.ts b/src/lib/server/compute/concurrency-limiter.ts deleted file mode 100644 index a67a959..0000000 --- a/src/lib/server/compute/concurrency-limiter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getComputeJobConcurrency } from '@openreader/compute-core'; - -export class ConcurrencyLimiter { - private readonly maxInFlight: number; - private inFlight = 0; - private readonly queue: Array<() => void> = []; - - constructor(limit: number) { - this.maxInFlight = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : 1; - } - - async run(fn: () => Promise): Promise { - await this.acquire(); - try { - return await fn(); - } finally { - this.release(); - } - } - - private acquire(): Promise { - if (this.inFlight < this.maxInFlight) { - this.inFlight += 1; - return Promise.resolve(); - } - return new Promise((resolve) => { - this.queue.push(() => { - this.inFlight += 1; - resolve(); - }); - }); - } - - private release(): void { - this.inFlight = Math.max(0, this.inFlight - 1); - const next = this.queue.shift(); - if (next) next(); - } -} - -export const LOCAL_COMPUTE_LIMITER = new ConcurrencyLimiter(getComputeJobConcurrency()); diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts index ecfea22..ac62a56 100644 --- a/src/lib/server/compute/index.ts +++ b/src/lib/server/compute/index.ts @@ -3,13 +3,9 @@ import { isWorkerClientConfigAvailable, WorkerComputeBackend } from '@/lib/serve let backendPromise: Promise | null = null; -async function createBackend(): Promise { - return new WorkerComputeBackend(); -} - export async function getCompute(): Promise { if (!backendPromise) { - backendPromise = createBackend().catch((error) => { + backendPromise = Promise.resolve(new WorkerComputeBackend()).catch((error) => { backendPromise = null; throw error; }); diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts index 5f21b57..ffd0951 100644 --- a/src/lib/server/compute/types.ts +++ b/src/lib/server/compute/types.ts @@ -6,8 +6,6 @@ import type { import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts'; import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; -export type ComputeMode = 'worker'; - export interface WhisperAlignInput extends WhisperAlignJobBase { audioBuffer?: TTSAudioBuffer; audioObjectKey?: string; diff --git a/tests/unit/compute-concurrency-limiter.spec.ts b/tests/unit/compute-concurrency-limiter.spec.ts deleted file mode 100644 index a37349b..0000000 --- a/tests/unit/compute-concurrency-limiter.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { ConcurrencyLimiter } from '../../src/lib/server/compute/concurrency-limiter'; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -test.describe('compute-concurrency-limiter', () => { - test('caps active jobs at configured limit', async () => { - const limiter = new ConcurrencyLimiter(2); - let inFlight = 0; - let maxInFlightSeen = 0; - - const jobs = Array.from({ length: 6 }, (_, i) => limiter.run(async () => { - inFlight += 1; - maxInFlightSeen = Math.max(maxInFlightSeen, inFlight); - await sleep(20 + (i % 2) * 5); - inFlight -= 1; - return i; - })); - - const result = await Promise.all(jobs); - expect(result).toEqual([0, 1, 2, 3, 4, 5]); - expect(maxInFlightSeen).toBe(2); - }); - - test('queues in FIFO order when saturated', async () => { - const limiter = new ConcurrencyLimiter(1); - const starts: number[] = []; - - const one = limiter.run(async () => { - starts.push(1); - await sleep(25); - }); - const two = limiter.run(async () => { - starts.push(2); - await sleep(5); - }); - const three = limiter.run(async () => { - starts.push(3); - await sleep(1); - }); - - await Promise.all([one, two, three]); - expect(starts).toEqual([1, 2, 3]); - }); - - test('releases slot after failure', async () => { - const limiter = new ConcurrencyLimiter(1); - let startedSecond = false; - - const first = limiter.run(async () => { - await sleep(10); - throw new Error('boom'); - }); - const second = limiter.run(async () => { - startedSecond = true; - return 'ok'; - }); - - await expect(first).rejects.toThrow('boom'); - await expect(second).resolves.toBe('ok'); - expect(startedSecond).toBeTruthy(); - }); -}); From 0b1ce1c8dcf060acb57fe7e942c4ea8ca99bd016 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:47:53 -0600 Subject: [PATCH 112/137] refactor(worker): remove unused job_state kv writes --- compute/worker/src/server.ts | 97 +----------------------------------- 1 file changed, 1 insertion(+), 96 deletions(-) diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 3df73f5..303ac9b 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -40,7 +40,6 @@ import type { WorkerOperationEvent, WhisperAlignJobRequest, WhisperAlignJobResult, - WorkerJobErrorShape, WorkerJobState, WorkerJobTiming, WorkerOperationKind, @@ -95,21 +94,6 @@ interface QueuedJob { payload: TPayload; } -interface StoredJobState { - jobId: string; - opId: string; - opKey: string; - kind: WorkerOperationKind; - status: WorkerJobState; - timestamp: number; - startedAt?: number; - updatedAt: number; - result?: Result; - error?: WorkerJobErrorShape; - timing?: WorkerJobTiming; - progress?: PdfLayoutProgress; -} - interface NatsSession { nc: NatsConnection; js: JetStreamClient; @@ -348,10 +332,6 @@ function isTerminalStatus(status: WorkerJobState): boolean { return status === 'succeeded' || status === 'failed'; } -function jobStateKvKey(jobId: string): string { - return `job_state.${jobId}`; -} - function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; const maybe = result as { parsedObjectKey?: unknown }; @@ -665,12 +645,6 @@ async function main(): Promise { const whisperJobCodec = createJsonCodec>(); const layoutJobCodec = createJsonCodec>(); - const jobStateCodec = createJsonCodec>(); - - const putJobState = async (state: StoredJobState): Promise => { - const { kv } = await ensureConnected(); - await kv.put(jobStateKvKey(state.jobId), jobStateCodec.encode(state)); - }; const operationStateStore = new JetStreamOperationStateStore({ getKv: async () => (await ensureConnected()).kv, @@ -686,17 +660,6 @@ async function main(): Promise { getJs: async () => (await ensureConnected()).js, whisperSubject: WHISPER_JOBS_SUBJECT, layoutSubject: LAYOUT_JOBS_SUBJECT, - onEnqueued: async (job) => { - await putJobState({ - jobId: job.jobId, - opId: job.opId, - opKey: job.opKey, - kind: job.kind, - status: 'queued', - timestamp: job.queuedAt, - updatedAt: job.queuedAt, - }); - }, }); const orchestrator = new OperationOrchestrator({ @@ -1035,18 +998,6 @@ async function main(): Promise { updatedAt: startedAt, ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), }); - await putJobState({ - jobId: decoded.jobId, - opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - status: 'running', - timestamp: decoded.queuedAt, - startedAt, - updatedAt: startedAt, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }); app.log.info({ worker: input.workerLabel, kind: decoded.kind, @@ -1072,18 +1023,6 @@ async function main(): Promise { ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), }); } - await putJobState({ - jobId: decoded!.jobId, - opId: decoded!.opId, - opKey: decoded!.opKey, - kind: decoded!.kind, - status: 'running', - timestamp: decoded!.queuedAt, - startedAt, - updatedAt, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }); }; heartbeat = setInterval(() => { @@ -1094,7 +1033,7 @@ async function main(): Promise { opId: decoded?.opId, jobId: decoded?.jobId, error: toErrorMessage(stateError), - }, 'failed to persist running heartbeat state'); + }, 'failed to persist operation heartbeat state'); }); }, RUNNING_HEARTBEAT_MS); @@ -1115,19 +1054,6 @@ async function main(): Promise { updatedAt: now, ...(resultTiming ? { timing: resultTiming } : {}), }); - await putJobState({ - jobId: decoded.jobId, - opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - status: 'succeeded', - timestamp: decoded.queuedAt, - startedAt, - updatedAt: now, - result: result as WhisperAlignJobResult | PdfLayoutJobResult, - ...(resultTiming ? { timing: resultTiming } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }); input.msg.ack(); const terminalDurationMs = safeDurationMs(startedAt, now); @@ -1164,7 +1090,6 @@ async function main(): Promise { const queueWaitMs = safeDurationMs(decoded.queuedAt, now); const queueWaitTiming = typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; - const status: WorkerJobState = hasRetriesLeft ? 'running' : 'failed'; const persistOpUpdate = hasRetriesLeft ? (latestProgress ? orchestrator.markProgress({ @@ -1193,26 +1118,6 @@ async function main(): Promise { error: toErrorMessage(stateError), }, 'failed to persist operation state'); }); - - await putJobState({ - jobId: decoded.jobId, - opId: decoded.opId, - opKey: decoded.opKey, - kind: decoded.kind, - status, - timestamp: decoded.queuedAt, - updatedAt: now, - ...(status === 'failed' ? { error: { message } } : {}), - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - ...(latestProgress ? { progress: latestProgress } : {}), - }).catch((stateError) => { - app.log.error({ - worker: input.workerLabel, - opId: decoded?.opId, - jobId: decoded?.jobId, - error: toErrorMessage(stateError), - }, 'failed to persist job state'); - }); } if (hasRetriesLeft) { From 1de7ed67b414f601f83a79736d70af97d9c6886a Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 18:54:11 -0600 Subject: [PATCH 113/137] refactor(worker): extract op helpers and shared parse mapping --- compute/worker/src/control-plane/jetstream.ts | 22 +- .../worker/src/control-plane/json-codec.ts | 17 + compute/worker/src/server.ts | 326 ++++++++++-------- .../api/documents/[id]/parsed/events/route.ts | 24 +- src/app/api/documents/[id]/parsed/route.ts | 43 +-- src/lib/server/compute/worker-parse-state.ts | 42 +++ 6 files changed, 267 insertions(+), 207 deletions(-) create mode 100644 compute/worker/src/control-plane/json-codec.ts create mode 100644 src/lib/server/compute/worker-parse-state.ts diff --git a/compute/worker/src/control-plane/jetstream.ts b/compute/worker/src/control-plane/jetstream.ts index 52d183f..642906d 100644 --- a/compute/worker/src/control-plane/jetstream.ts +++ b/compute/worker/src/control-plane/jetstream.ts @@ -14,6 +14,7 @@ import type { WhisperAlignJobRequest, WorkerOperationKind, } from '@openreader/compute-core/api-contracts'; +import { createJsonCodec } from './json-codec'; export interface KvEntryLike { operation?: string; @@ -28,24 +29,6 @@ export interface KvStoreLike { update(key: string, data: Uint8Array, version: number): Promise; } -type JsonCodec = { - encode(value: T): Uint8Array; - decode(data: Uint8Array): T; -}; - -function createJsonCodec(): JsonCodec { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - return { - encode(value: T): Uint8Array { - return encoder.encode(JSON.stringify(value)); - }, - decode(data: Uint8Array): T { - return JSON.parse(decoder.decode(data)) as T; - }, - }; -} - function toErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; return String(error); @@ -64,7 +47,8 @@ interface OpIndexEntry { opId: string; } -const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; +export const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; +export const OP_EVENTS_SUBJECT_WILDCARD = `${OP_EVENTS_SUBJECT_PREFIX}.*`; export function hashOpKey(opKey: string): string { return createHash('sha256').update(opKey).digest('hex'); diff --git a/compute/worker/src/control-plane/json-codec.ts b/compute/worker/src/control-plane/json-codec.ts new file mode 100644 index 0000000..27d3ea0 --- /dev/null +++ b/compute/worker/src/control-plane/json-codec.ts @@ -0,0 +1,17 @@ +export type JsonCodec = { + encode(value: T): Uint8Array; + decode(data: Uint8Array): T; +}; + +export function createJsonCodec(): JsonCodec { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + return { + encode(value: T): Uint8Array { + return encoder.encode(JSON.stringify(value)); + }, + decode(data: Uint8Array): T { + return JSON.parse(decoder.decode(data)) as T; + }, + }; +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 303ac9b..8b82485 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -50,10 +50,12 @@ import type { import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { JetStreamOperationEventStream, + OP_EVENTS_SUBJECT_WILDCARD, JetStreamOperationQueue, JetStreamOperationStateStore, hashOpKey, } from './control-plane/jetstream'; +import { type JsonCodec, createJsonCodec } from './control-plane/json-codec'; const JOBS_STREAM_NAME = 'compute_jobs'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; @@ -65,7 +67,6 @@ const COMPUTE_STATE_BUCKET = 'compute_state'; const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000; const LOOP_ERROR_BACKOFF_MS = 500; const RUNNING_HEARTBEAT_MS = 5000; -const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const WHISPER_MAX_DELIVER = 1; @@ -105,11 +106,6 @@ interface NatsSession { type StreamedOperationState = WorkerOperationState; -type JsonCodec = { - encode(value: T): Uint8Array; - decode(data: Uint8Array): T; -}; - function requireEnv(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required`); @@ -281,19 +277,6 @@ function isAlreadyExistsError(error: unknown): boolean { return message.includes('already in use') || message.includes('already exists'); } -function createJsonCodec(): JsonCodec { - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - return { - encode(value: T): Uint8Array { - return encoder.encode(JSON.stringify(value)); - }, - decode(data: Uint8Array): T { - return JSON.parse(decoder.decode(data)) as T; - }, - }; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -395,7 +378,7 @@ async function ensureJetStreamResources( const eventsStreamConfig = { name: EVENTS_STREAM_NAME, - subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`], + subjects: [OP_EVENTS_SUBJECT_WILDCARD], retention: RetentionPolicy.Limits, storage: StorageType.File, max_bytes: eventsMaxBytes, @@ -408,7 +391,7 @@ async function ensureJetStreamResources( } catch (error) { if (!isAlreadyExistsError(error)) throw error; await jsm.streams.update(EVENTS_STREAM_NAME, { - subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`], + subjects: [OP_EVENTS_SUBJECT_WILDCARD], max_bytes: eventsMaxBytes, max_age: nanos(COMPUTE_STATE_TTL_MS), num_replicas: natsReplicas, @@ -973,7 +956,7 @@ async function main(): Promise { }; }; - async function processMessage(input: { + type ProcessMessageInput = { msg: JsMsg; codec: JsonCodec>; run: ( @@ -982,81 +965,212 @@ async function main(): Promise { hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, ) => Promise; workerLabel: string; - }): Promise { - let decoded: QueuedJob | null = null; - let heartbeat: NodeJS.Timeout | null = null; - let latestProgress: PdfLayoutProgress | undefined; - try { - decoded = input.codec.decode(input.msg.data); - const startedAt = Date.now(); - const queueWaitMs = safeDurationMs(decoded.queuedAt, startedAt); - const queueWaitTiming = typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; + }; - await orchestrator.markRunning({ - opId: decoded.opId, - startedAt, - updatedAt: startedAt, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), + type ProcessMessageContext = { + decoded: QueuedJob; + workerLabel: string; + startedAt: number; + queueWaitTiming?: { queueWaitMs: number }; + latestProgress?: PdfLayoutProgress; + }; + + function buildQueueWaitTiming(queuedAt: number, now: number): { queueWaitMs: number } | undefined { + const queueWaitMs = safeDurationMs(queuedAt, now); + return typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; + } + + function extractTiming(result: unknown): WorkerJobTiming | undefined { + if (!result || typeof result !== 'object' || !('timing' in result)) return undefined; + return (result as { timing?: WorkerJobTiming }).timing; + } + + async function markRunning( + context: ProcessMessageContext, + updatedAt: number, + options?: { includeStartedAt?: boolean }, + ): Promise { + if (context.latestProgress) { + await orchestrator.markProgress({ + opId: context.decoded.opId, + progress: context.latestProgress, + updatedAt, + ...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}), }); + return; + } + + await orchestrator.markRunning({ + opId: context.decoded.opId, + ...(options?.includeStartedAt === false ? {} : { startedAt: context.startedAt }), + updatedAt, + ...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}), + }); + } + + async function markProgress( + context: ProcessMessageContext, + progress: PdfLayoutProgress, + updatedAt: number, + ): Promise { + context.latestProgress = progress; + await markRunning(context, updatedAt); + } + + async function markTerminal(input: { + context: ProcessMessageContext; + status: 'succeeded' | 'failed'; + result?: TResult; + errorMessage?: string; + timing?: WorkerJobTiming; + updatedAt: number; + }): Promise { + if (input.status === 'succeeded') { + await orchestrator.markSucceeded({ + opId: input.context.decoded.opId, + result: input.result as WhisperAlignJobResult | PdfLayoutJobResult, + updatedAt: input.updatedAt, + ...(input.timing ? { timing: input.timing } : {}), + }); + return; + } + + await orchestrator.markFailed({ + opId: input.context.decoded.opId, + error: { message: input.errorMessage ?? 'unknown worker failure' }, + updatedAt: input.updatedAt, + ...(input.timing ? { timing: input.timing } : {}), + }); + } + + async function handleRetry(input: { + context: ProcessMessageContext | null; + msg: JsMsg; + errorMessage: string; + }): Promise { + const deliveryCount = input.msg.info.deliveryCount; + const isWhisperAlign = input.context?.decoded.kind === 'whisper_align'; + const maxAttempts = isWhisperAlign ? WHISPER_MAX_DELIVER : pdfAttempts; + const hasRetriesLeft = !isWhisperAlign && deliveryCount < maxAttempts; + + if (input.context) { + const now = Date.now(); + const retryTiming = buildQueueWaitTiming(input.context.decoded.queuedAt, now); + const persistOpUpdate = hasRetriesLeft + ? (input.context.latestProgress + ? orchestrator.markProgress({ + opId: input.context.decoded.opId, + progress: input.context.latestProgress, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + }) + : orchestrator.markRunning({ + opId: input.context.decoded.opId, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + })) + : markTerminal({ + context: input.context, + status: 'failed', + errorMessage: input.errorMessage, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + }); + + await persistOpUpdate.catch((stateError) => { + app.log.error({ + worker: input.context?.workerLabel, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist operation state'); + }); + } + + if (hasRetriesLeft) { + input.msg.nak(); + app.log.error({ + worker: input.context?.workerLabel, + kind: input.context?.decoded.kind, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + status: 'running', + error: input.errorMessage, + deliveryCount, + maxAttempts, + retryAction: 'nack_retry', + }, 'job.terminal'); + return; + } + + input.msg.term(input.errorMessage); + app.log.error({ + worker: input.context?.workerLabel, + kind: input.context?.decoded.kind, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + status: 'failed', + error: input.errorMessage, + deliveryCount, + maxAttempts, + retrySuppressed: isWhisperAlign ? 'whisper_align' : undefined, + retryAction: 'term', + }, 'job.terminal'); + } + + async function processMessage(input: ProcessMessageInput): Promise { + let context: ProcessMessageContext | null = null; + let heartbeat: NodeJS.Timeout | null = null; + try { + const decoded = input.codec.decode(input.msg.data); + const startedAt = Date.now(); + context = { + decoded, + workerLabel: input.workerLabel, + startedAt, + queueWaitTiming: buildQueueWaitTiming(decoded.queuedAt, startedAt), + }; + + await markRunning(context, startedAt); app.log.info({ worker: input.workerLabel, kind: decoded.kind, opId: decoded.opId, jobId: decoded.jobId, - queueWaitMs: queueWaitMs ?? null, + queueWaitMs: context.queueWaitTiming?.queueWaitMs ?? null, deliveryCount: input.msg.info.deliveryCount, }, 'job.started'); - const persistRunningState = async (updatedAt: number): Promise => { - if (latestProgress) { - await orchestrator.markProgress({ - opId: decoded!.opId, - progress: latestProgress, - updatedAt, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - }); - } else { - await orchestrator.markRunning({ - opId: decoded!.opId, - startedAt, - updatedAt, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - }); - } - }; - heartbeat = setInterval(() => { const now = Date.now(); - void persistRunningState(now).catch((stateError) => { + void markRunning(context!, now).catch((stateError) => { app.log.error({ worker: input.workerLabel, - opId: decoded?.opId, - jobId: decoded?.jobId, + opId: context?.decoded.opId, + jobId: context?.decoded.jobId, error: toErrorMessage(stateError), }, 'failed to persist operation heartbeat state'); }); }, RUNNING_HEARTBEAT_MS); - const result = await input.run(decoded.payload, queueWaitMs ?? 0, { + const result = await input.run(decoded.payload, context.queueWaitTiming?.queueWaitMs ?? 0, { onProgress: async (progress) => { - latestProgress = progress; - await persistRunningState(Date.now()); + await markProgress(context!, progress, Date.now()); }, }); - const resultTiming = result && typeof result === 'object' && 'timing' in result - ? (result as { timing?: WorkerJobTiming }).timing - : undefined; + const resultTiming = extractTiming(result); const now = Date.now(); - await orchestrator.markSucceeded({ - opId: decoded.opId, - result: result as WhisperAlignJobResult | PdfLayoutJobResult, + await markTerminal({ + context, + status: 'succeeded', + result, + timing: resultTiming, updatedAt: now, - ...(resultTiming ? { timing: resultTiming } : {}), }); input.msg.ack(); - const terminalDurationMs = safeDurationMs(startedAt, now); + const terminalDurationMs = safeDurationMs(context.startedAt, now); const slowJobLogThresholdMs = SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND[decoded.kind]; if ((terminalDurationMs ?? 0) >= slowJobLogThresholdMs) { app.log.info({ @@ -1079,75 +1193,11 @@ async function main(): Promise { timing: resultTiming ?? null, }, 'job.terminal'); } catch (error) { - const message = toErrorMessage(error); - const deliveryCount = input.msg.info.deliveryCount; - const isWhisperAlign = decoded?.kind === 'whisper_align'; - const maxAttempts = isWhisperAlign ? WHISPER_MAX_DELIVER : pdfAttempts; - const hasRetriesLeft = !isWhisperAlign && deliveryCount < maxAttempts; - - if (decoded) { - const now = Date.now(); - const queueWaitMs = safeDurationMs(decoded.queuedAt, now); - const queueWaitTiming = typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; - - const persistOpUpdate = hasRetriesLeft - ? (latestProgress - ? orchestrator.markProgress({ - opId: decoded.opId, - progress: latestProgress, - updatedAt: now, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - }) - : orchestrator.markRunning({ - opId: decoded.opId, - updatedAt: now, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - })) - : orchestrator.markFailed({ - opId: decoded.opId, - error: { message }, - updatedAt: now, - ...(queueWaitTiming ? { timing: queueWaitTiming } : {}), - }); - - await persistOpUpdate.catch((stateError) => { - app.log.error({ - worker: input.workerLabel, - opId: decoded?.opId, - jobId: decoded?.jobId, - error: toErrorMessage(stateError), - }, 'failed to persist operation state'); - }); - } - - if (hasRetriesLeft) { - input.msg.nak(); - app.log.error({ - worker: input.workerLabel, - kind: decoded?.kind, - opId: decoded?.opId, - jobId: decoded?.jobId, - status: 'running', - error: message, - deliveryCount, - maxAttempts, - retryAction: 'nack_retry', - }, 'job.terminal'); - } else { - input.msg.term(message); - app.log.error({ - worker: input.workerLabel, - kind: decoded?.kind, - opId: decoded?.opId, - jobId: decoded?.jobId, - status: 'failed', - error: message, - deliveryCount, - maxAttempts, - retrySuppressed: isWhisperAlign ? 'whisper_align' : undefined, - retryAction: 'term', - }, 'job.terminal'); - } + await handleRetry({ + context, + msg: input.msg, + errorMessage: toErrorMessage(error), + }); } finally { if (heartbeat) clearInterval(heartbeat); } diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 077b7e0..315b3fc 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; +import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state'; import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; @@ -47,29 +48,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { - switch (status) { - case 'queued': - return 'pending'; - case 'running': - return 'running'; - case 'succeeded': - return 'ready'; - case 'failed': - return 'failed'; - default: - return 'pending'; - } -} - -function snapshotFromWorkerState(state: WorkerOperationState): ParsedSnapshot { - const parseStatus = mapWorkerStatusToParseStatus(state.status); - return { - parseStatus, - parseProgress: parseStatus === 'running' ? (state.progress ?? null) : null, - }; -} - async function toSnapshotState(row: ParseRow): Promise { const state = await healStaleDocumentParseState({ documentId: row.id, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 5bcb2dc..492f1db 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -4,6 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; +import { mergeNonReadyParseSnapshot } from '@/lib/server/compute/worker-parse-state'; import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { getParsedDocumentBlob, @@ -21,7 +22,7 @@ import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state- import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { ParsedPdfDocument } from '@/types/parsed-pdf'; -import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; +import type { PdfLayoutJobResult } from '@openreader/compute-core/api-contracts'; export const dynamic = 'force-dynamic'; @@ -37,21 +38,6 @@ function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); } -function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']) { - switch (status) { - case 'queued': - return 'pending' as const; - case 'running': - return 'running' as const; - case 'succeeded': - return 'ready' as const; - case 'failed': - return 'failed' as const; - default: - return 'pending' as const; - } -} - export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -103,12 +89,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string if (opId && effectiveStatus !== 'ready') { const workerState = await fetchWorkerOperationState(opId); if (workerState && workerState.opId === opId) { - const workerStatus = mapWorkerStatusToParseStatus(workerState.status); - // Keep DB/blob as source of truth for "ready"; prefer worker only for active/failed states. - if (workerStatus === 'pending' || workerStatus === 'running' || workerStatus === 'failed') { - effectiveStatus = workerStatus; - effectiveProgress = workerStatus === 'running' ? (workerState.progress ?? null) : null; - } + const merged = mergeNonReadyParseSnapshot({ + parseStatus: effectiveStatus, + parseProgress: effectiveProgress, + workerState, + }); + effectiveStatus = merged.parseStatus; + effectiveProgress = merged.parseProgress; } } @@ -226,11 +213,13 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string if (opId && effectiveStatus !== 'ready') { const workerState = await fetchWorkerOperationState(opId); if (workerState && workerState.opId === opId) { - const workerStatus = mapWorkerStatusToParseStatus(workerState.status); - if (workerStatus === 'pending' || workerStatus === 'running' || workerStatus === 'failed') { - effectiveStatus = workerStatus; - effectiveProgress = workerStatus === 'running' ? (workerState.progress ?? null) : null; - } + const merged = mergeNonReadyParseSnapshot({ + parseStatus: effectiveStatus, + parseProgress: effectiveProgress, + workerState, + }); + effectiveStatus = merged.parseStatus; + effectiveProgress = merged.parseProgress; } } diff --git a/src/lib/server/compute/worker-parse-state.ts b/src/lib/server/compute/worker-parse-state.ts new file mode 100644 index 0000000..c0eaa3a --- /dev/null +++ b/src/lib/server/compute/worker-parse-state.ts @@ -0,0 +1,42 @@ +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; + +export function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { + switch (status) { + case 'queued': + return 'pending'; + case 'running': + return 'running'; + case 'succeeded': + return 'ready'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +} + +export function snapshotFromWorkerState( + state: WorkerOperationState, +): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } { + const parseStatus = mapWorkerStatusToParseStatus(state.status); + return { + parseStatus, + parseProgress: parseStatus === 'running' ? (state.progress ?? null) : null, + }; +} + +export function mergeNonReadyParseSnapshot(input: { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + workerState: WorkerOperationState; +}): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } { + const workerSnapshot = snapshotFromWorkerState(input.workerState); + if (workerSnapshot.parseStatus === 'ready') { + return { + parseStatus: input.parseStatus, + parseProgress: input.parseProgress, + }; + } + return workerSnapshot; +} From b2cf4aa7aa932196d8cc123e53c83fb0bdcefa84 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 19:00:07 -0600 Subject: [PATCH 114/137] ci(playwright): install nats for embedded worker startup --- .github/workflows/playwright.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f266b8b..0f9768c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,6 +34,13 @@ jobs: sudo install -m 0755 ./weed /usr/local/bin/weed rm -f ./weed weed version + - name: Install NATS server binary + run: | + curl -fsSL -o /tmp/nats-server.zip \ + https://github.com/nats-io/nats-server/releases/download/v2.12.1/nats-server-v2.12.1-linux-amd64.zip + unzip -j /tmp/nats-server.zip '*/nats-server' -d /tmp + sudo install -m 0755 /tmp/nats-server /usr/local/bin/nats-server + nats-server -v - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build app and enforce bundle guard From cd530a365d7dddc5f72a297c499cbe9ab6b07b14 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 19:22:07 -0600 Subject: [PATCH 115/137] refactor(whisper): migrate default ONNX model references to q4 variant Update all configuration, manifest, and documentation references to use the q4 Whisper ONNX model files instead of int8. Adjust expected file names, hashes, and environment variable descriptions to reflect this new default. This aligns runtime, deployment, and developer documentation with the updated model artifact expectations for improved consistency. --- .env.example | 4 ++++ compute/core/src/whisper/assets/manifest.json | 20 +++++++++---------- compute/core/src/whisper/model.ts | 18 ++++++++--------- compute/worker/.env.example | 4 ++++ docs-site/docs/deploy/compute-worker.md | 8 ++++++-- docs-site/docs/deploy/local-development.md | 2 +- .../docs/reference/environment-variables.md | 4 ++-- docs-site/docs/reference/stack.md | 2 +- 8 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 36cc589..7ed67d1 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,10 @@ IMPORT_LIBRARY_DIRS= # COMPUTE_OP_STALE_MS=1800000 # Optional Whisper ONNX base URL override (must contain all expected files) +# Default expected Whisper variant is q4: +# - onnx/encoder_model_q4.onnx +# - onnx/decoder_model_merged_q4.onnx +# - onnx/decoder_with_past_model_q4.onnx # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main # Optional PDF layout ONNX base URL override (must contain all expected files) diff --git a/compute/core/src/whisper/assets/manifest.json b/compute/core/src/whisper/assets/manifest.json index 2fffd3d..b1adda1 100644 --- a/compute/core/src/whisper/assets/manifest.json +++ b/compute/core/src/whisper/assets/manifest.json @@ -1,5 +1,5 @@ { - "name": "whisper-base_timestamped-int8", + "name": "whisper-base_timestamped-q4", "version": "onnx-community/whisper-base_timestamped@608c49e61301901684bc36cac8f74b95ff6b5a8e", "files": [ { @@ -53,19 +53,19 @@ "size": 2194 }, { - "path": "onnx/encoder_model_int8.onnx", - "sha256": "152da96dd8ff3f28f3fadabc2e8960405a277846453ff94ed411fe935a72917f", - "size": 23159150 + "path": "onnx/encoder_model_q4.onnx", + "sha256": "0df94e35822653ba16e23c3f19f05b9b7fe7aae97884d1b277e02044f33c4880", + "size": 18771046 }, { - "path": "onnx/decoder_model_merged_int8.onnx", - "sha256": "cf9a8d5bcddc0917a0078135b484cedcaf44f28909cd91910abd29dced9171db", - "size": 53712708 + "path": "onnx/decoder_model_merged_q4.onnx", + "sha256": "fc1902ce2e42c69b2346d8e2a98898c60c01da1e6a64ae90f41d22350ac7db13", + "size": 123738327 }, { - "path": "onnx/decoder_with_past_model_int8.onnx", - "sha256": "bdd92860d0ed7dff2aca623963378cbba1b617bfae127356db1c8aa8baa930ef", - "size": 50131672 + "path": "onnx/decoder_with_past_model_q4.onnx", + "sha256": "d864ca26509968b00d92e6823c4db7ac460c106f9228a21bc5199e9893fe4126", + "size": 121378741 }, { "path": "LICENSE.txt", diff --git a/compute/core/src/whisper/model.ts b/compute/core/src/whisper/model.ts index 100bc6f..4c5a021 100644 --- a/compute/core/src/whisper/model.ts +++ b/compute/core/src/whisper/model.ts @@ -14,9 +14,9 @@ export const WHISPER_CONFIG_PATH = path.join(MODEL_DIR, 'config.json'); export const WHISPER_GENERATION_CONFIG_PATH = path.join(MODEL_DIR, 'generation_config.json'); export const WHISPER_TOKENIZER_PATH = path.join(MODEL_DIR, 'tokenizer.json'); export const WHISPER_TOKENIZER_CONFIG_PATH = path.join(MODEL_DIR, 'tokenizer_config.json'); -export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_int8.onnx'); -export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_int8.onnx'); -export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_int8.onnx'); +export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_q4.onnx'); +export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_q4.onnx'); +export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_q4.onnx'); const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main'; const WHISPER_MODEL_BASE_URL_ENV = 'WHISPER_MODEL_BASE_URL'; @@ -32,9 +32,9 @@ const MODEL_RELATIVE_PATHS: string[] = [ 'added_tokens.json', 'preprocessor_config.json', 'special_tokens_map.json', - 'onnx/encoder_model_int8.onnx', - 'onnx/decoder_model_merged_int8.onnx', - 'onnx/decoder_with_past_model_int8.onnx', + 'onnx/encoder_model_q4.onnx', + 'onnx/decoder_model_merged_q4.onnx', + 'onnx/decoder_with_past_model_q4.onnx', ]; const DEFAULT_URLS: Record = { @@ -48,9 +48,9 @@ const DEFAULT_URLS: Record = { 'added_tokens.json': `${BASE_MODEL_URL}/added_tokens.json`, 'preprocessor_config.json': `${BASE_MODEL_URL}/preprocessor_config.json`, 'special_tokens_map.json': `${BASE_MODEL_URL}/special_tokens_map.json`, - 'onnx/encoder_model_int8.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_int8.onnx`, - 'onnx/decoder_model_merged_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_int8.onnx`, - 'onnx/decoder_with_past_model_int8.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_int8.onnx`, + 'onnx/encoder_model_q4.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_q4.onnx`, + 'onnx/decoder_model_merged_q4.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_q4.onnx`, + 'onnx/decoder_with_past_model_q4.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_q4.onnx`, }; type ManifestEntry = { path: string; sha256?: string; size?: number }; diff --git a/compute/worker/.env.example b/compute/worker/.env.example index 3c38f5d..12bc936 100644 --- a/compute/worker/.env.example +++ b/compute/worker/.env.example @@ -44,5 +44,9 @@ S3_FORCE_PATH_STYLE=true # COMPUTE_NATS_REPLICAS=1 # COMPUTE_OP_STALE_MS=1800000 # Optional model mirrors +# Default expected Whisper variant is q4: +# - onnx/encoder_model_q4.onnx +# - onnx/decoder_model_merged_q4.onnx +# - onnx/decoder_with_past_model_q4.onnx # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main # PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md index 5f027ee..4b220bd 100644 --- a/docs-site/docs/deploy/compute-worker.md +++ b/docs-site/docs/deploy/compute-worker.md @@ -55,7 +55,7 @@ Advanced tuning (usually leave unset unless you need overrides): - `COMPUTE_JOB_CONCURRENCY=1` (shared total compute jobs across whisper + PDF) - `COMPUTE_WHISPER_TIMEOUT_MS=30000` - `COMPUTE_PDF_TIMEOUT_MS=300000` -- `WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` (optional override) +- `WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` (optional override, q4 defaults) - `PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main` (optional override) - `COMPUTE_PDF_JOB_ATTEMPTS=1` (PDF layout retry attempts) - `COMPUTE_JOBS_STREAM_MAX_BYTES=268435456` (256MB JetStream jobs stream cap) @@ -79,7 +79,7 @@ COMPUTE_WORKER_TOKEN= # COMPUTE_OP_STALE_MS=1800000 ``` -Model artifact overrides (`WHISPER_MODEL_BASE_URL`, `PDF_LAYOUT_MODEL_BASE_URL`) are worker runtime variables and should be set on the compute worker service environment. +Model artifact overrides (`WHISPER_MODEL_BASE_URL`, `PDF_LAYOUT_MODEL_BASE_URL`) are worker runtime variables and should be set on the compute worker service environment. Current Whisper defaults expect q4 artifacts (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`) under that base URL. `COMPUTE_OP_STALE_MS` is shared by both services in worker mode: @@ -168,6 +168,10 @@ COMPUTE_WORKER_TOKEN= # COMPUTE_WHISPER_TIMEOUT_MS=30000 # COMPUTE_PDF_TIMEOUT_MS=300000 # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main +# # Expects q4 files at that base: +# # - onnx/encoder_model_q4.onnx +# # - onnx/decoder_model_merged_q4.onnx +# # - onnx/decoder_with_past_model_q4.onnx # PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main # COMPUTE_PDF_JOB_ATTEMPTS=1 # COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 14ca0a4..8498144 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -155,7 +155,7 @@ No extra native Whisper CLI build step is required. Word-by-word highlighting and PDF layout parsing are worker-backed in current releases. -If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env`. +If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env` (current defaults expect q4 Whisper files at that base URL).
diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 005ef32..f479496 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -65,7 +65,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) | | `COMPUTE_OP_STALE_MS` | Heavy compute backend | `max(30m, 4x max compute timeout)` | Shared stale window for worker op replacement and app-side stale PDF parse-state healing | | `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | -| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped int8 downloads | +| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped q4 downloads | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -460,7 +460,7 @@ Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureMod Optional base URL override for the built-in ONNX Whisper alignment model downloader. - Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` -- Default model variant: int8 (`encoder_model_int8.onnx`, `decoder_model_merged_int8.onnx`, `decoder_with_past_model_int8.onnx`) +- Default model variant: q4 (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`) - The base URL must host all expected manifest files under the same relative paths. - Configure this on the worker service env (not only the app server env) diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 49f5f09..ea847e6 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -49,7 +49,7 @@ Monorepo packages under `compute/`: - **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts - ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers` - - Whisper alignment: `onnx-community/whisper-base_timestamped` (int8) for word-level timestamps + - Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps - PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing - PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization - Utilities: `jszip`, `ffmpeg-static` From 16e94a1587ce3e25f8be47b02e703d042132dff1 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 19:33:41 -0600 Subject: [PATCH 116/137] build(docker): include compute/core and worker package manifests for deps Add compute/core/package.json and compute/worker/package.json to Docker build context to ensure all workspace dependencies are correctly installed during image creation. This change prevents missing dependency issues for subpackages when building in isolated environments. --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7d038f3..ab8d381 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,10 @@ RUN npm install -g pnpm@11.1.2 # Create app directory WORKDIR /app -# Copy package files +# Copy workspace manifests needed for dependency installation COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY compute/core/package.json ./compute/core/package.json +COPY compute/worker/package.json ./compute/worker/package.json # Install dependencies RUN pnpm install --frozen-lockfile From ebc1dbcda08dacb7d46d6ce53f17949ba96d2c23 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 19:48:55 -0600 Subject: [PATCH 117/137] ci(playwright): install nats-server from linux tarball asset --- .github/workflows/playwright.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0f9768c..dfe0e8d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -36,10 +36,10 @@ jobs: weed version - name: Install NATS server binary run: | - curl -fsSL -o /tmp/nats-server.zip \ - https://github.com/nats-io/nats-server/releases/download/v2.12.1/nats-server-v2.12.1-linux-amd64.zip - unzip -j /tmp/nats-server.zip '*/nats-server' -d /tmp - sudo install -m 0755 /tmp/nats-server /usr/local/bin/nats-server + curl -fsSL -o /tmp/nats-server.tar.gz \ + https://github.com/nats-io/nats-server/releases/download/v2.12.1/nats-server-v2.12.1-linux-amd64.tar.gz + tar -xzf /tmp/nats-server.tar.gz -C /tmp + sudo install -m 0755 /tmp/nats-server-v2.12.1-linux-amd64/nats-server /usr/local/bin/nats-server nats-server -v - name: Install dependencies run: pnpm install --frozen-lockfile From c717f7e33e490ba740d37ad0eb1004b886883648 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 26 May 2026 20:08:02 -0600 Subject: [PATCH 118/137] fix(storage): restore main-like upload path without pre-presign bucket scan --- src/lib/server/documents/blobstore.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index 41aa97a..5ecae17 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -100,35 +100,11 @@ function legacyDocumentParsedKey(id: string, namespace: string | null): string { return `${cfg.prefix}/documents_v1/${nsSegment}${id}/parsed.v1.json`; } -async function cleanupLegacyParsedPathCollision(id: string, namespace: string | null): Promise { - const cfg = getS3Config(); - const client = getS3ProxyClient(); - const key = documentKey(id, namespace); - const legacyPrefix = `${key}/`; - const legacyParsedKey = legacyDocumentParsedKey(id, namespace); - - const legacyObjects = await client.send( - new ListObjectsV2Command({ - Bucket: cfg.bucket, - Prefix: legacyPrefix, - MaxKeys: 1, - }), - ); - const hasLegacyChildren = (legacyObjects.Contents?.length ?? 0) > 0; - if (!hasLegacyChildren) return; - - await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); - await deleteDocumentPrefix(legacyPrefix).catch(() => undefined); - await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })).catch(() => undefined); -} - export async function presignPut( id: string, contentType: string, namespace: string | null, ): Promise<{ url: string; headers: Record }> { - await cleanupLegacyParsedPathCollision(id, namespace); - const cfg = getS3Config(); const client = getS3Client(); const key = documentKey(id, namespace); @@ -278,8 +254,6 @@ export async function putDocumentBlob( contentType: string, namespace: string | null, ): Promise { - await cleanupLegacyParsedPathCollision(id, namespace); - const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); From 14b1b5602fc36cd43e11d876dbd382fe8fdd8653 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 00:08:42 -0600 Subject: [PATCH 119/137] fix(parse-progress): persist opId only after worker op exists --- src/lib/server/jobs/user-pdf-layout-job.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts index 0dd7eba..348a9ec 100644 --- a/src/lib/server/jobs/user-pdf-layout-job.ts +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { and, eq, inArray, isNull } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; @@ -200,12 +199,10 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise const coordinator = [...scopedRows].sort((a, b) => a.userId.localeCompare(b.userId))[0]; if (!coordinator) return; - const claimOpId = randomUUID(); const runningStateData: DocumentParseState = { status: 'running', progress: null, updatedAt: Date.now(), - opId: claimOpId, }; const runningState = stringifyDocumentParseState(runningStateData); @@ -239,12 +236,12 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise }); const compute = await getCompute(); - let activeOpId: string = claimOpId; + let activeOpId: string | undefined; let activeJobId: string | undefined; let lastProgressWriteAt = 0; let lastSnapshotWriteAt = 0; let lastSnapshotStatus: 'pending' | 'running' | null = null; - let lastSnapshotOpId: string = claimOpId; + let lastSnapshotOpId: string | undefined; let lastSnapshotJobId: string | undefined; const persistRunningState = async (state: DocumentParseState): Promise => { @@ -280,7 +277,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise status: mappedStatus, progress: snapshot.progress ?? null, updatedAt: now, - opId: activeOpId, + ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; await persistRunningState(nextState); @@ -306,7 +303,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise phase: progress.phase, }, updatedAt: Date.now(), - opId: activeOpId, + ...(activeOpId ? { opId: activeOpId } : {}), ...(activeJobId ? { jobId: activeJobId } : {}), }; await persistRunningState(runningProgressState); From 22f132bbc882159356e870ea28c9c973dc105717 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 00:40:12 -0600 Subject: [PATCH 120/137] hard-cut onboarding modal flow orchestration --- src/app/(app)/app/layout.tsx | 7 +- src/app/(app)/layout.tsx | 2 - src/components/SettingsModal.tsx | 49 +-- src/components/auth/ClaimDataModal.tsx | 76 +---- .../documents/DexieMigrationModal.tsx | 92 ++---- src/contexts/OnboardingFlowContext.tsx | 307 ++++++++++++++++++ src/hooks/useOnboardingCoordinator.ts | 184 ----------- 7 files changed, 371 insertions(+), 346 deletions(-) create mode 100644 src/contexts/OnboardingFlowContext.tsx delete mode 100644 src/hooks/useOnboardingCoordinator.ts diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx index f8abbbb..947ab4f 100644 --- a/src/app/(app)/app/layout.tsx +++ b/src/app/(app)/app/layout.tsx @@ -4,16 +4,13 @@ import type { ReactNode } from 'react'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { DocumentProvider } from '@/contexts/DocumentContext'; -import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; +import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; export default function AppHomeLayout({ children }: { children: ReactNode }) { return ( - <> - {children} - - + {children} ); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fb14c2c..7cb8cb8 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; -import ClaimDataPopup from '@/components/auth/ClaimDataModal'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) { githubAuthEnabled={githubAuthEnabled} >
- {authEnabled && }
{children}
{ - const appConfig = await getAppConfig(); - const row = appConfig as Record | null; - const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey; - const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey; - - return { - privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false, - firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : await getFirstVisit(), + useEffect(() => { + registerSettingsController({ + open: openSettings, + close: closeSettings, + }); + return () => { + registerSettingsController(null); }; - }, []); - - const markFirstVisitSettingsOpened = useCallback(async () => { - await setFirstVisit(true); - }, []); - - const { requestOpenSettings } = useOnboardingCoordinator({ - authEnabled, - isSessionPending, - sessionUserId: session?.user?.id, - appVersion: runtimeConfig.appVersion, - isSettingsOpen: isOpen, - readLocalSnapshot: readLocalOnboardingSnapshot, - markFirstVisitSettingsOpened, - postChangelogVersionCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion), - openSettings, - closeSettings, - }); + }, [closeSettings, openSettings, registerSettingsController]); useEffect(() => { setLocalApiKey(apiKey); @@ -530,7 +509,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { - + void; + onClaimed: () => void; +}; + +export default function ClaimDataModal({ + isOpen, + claimableCounts, + onDismiss, + onClaimed, +}: ClaimDataModalProps) { const router = useRouter(); - const [isOpen, setIsOpen] = useState(false); - const [hasChecked, setHasChecked] = useState(false); const [isClaiming, setIsClaiming] = useState(false); - const [claimableCounts, setClaimableCounts] = useState({ - documents: 0, - audiobooks: 0, - preferences: 0, - progress: 0, - }); - const user = sessionData?.user; - const userId = user?.id; - - const checkClaimableData = useCallback(async () => { - setHasChecked(true); - - try { - const res = await fetch('/api/user/claim', { - method: 'GET', - }); - if (res.ok) { - const data = await res.json(); - const counts = toClaimableCounts(data); - setClaimableCounts(counts); - - if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) { - setIsOpen(true); - } - } - } catch (e) { - console.error(e); - } - }, []); - - useEffect(() => { - // Reset per-user guard so account switches trigger a fresh check. - setHasChecked(false); - }, [userId]); - - useEffect(() => { - // Only check once per authenticated user - if (userId && !user?.isAnonymous && !hasChecked) { - checkClaimableData(); - } - }, [userId, user?.isAnonymous, hasChecked, checkClaimableData]); const handleClaim = async () => { setIsClaiming(true); @@ -93,8 +60,7 @@ export default function ClaimDataModal() { + `${claimed.preferences} preference set(s), and ` + `${claimed.progress} reading progress record(s)!`, ); - - setIsOpen(false); + onClaimed(); router.refresh(); return; } @@ -107,15 +73,9 @@ export default function ClaimDataModal() { } }; - const handleDismiss = () => { - // Close the modal for this session - will reappear on next page load/refresh - setIsOpen(false); - // Keep hasChecked = true so useEffect doesn't re-trigger in this session - }; - return ( - +