Merge pull request #90: Refactor compute architecture with workerized ONNX PDF/Whisper pipeline

- Monorepo Architecture: Separates processing into compute/core and compute/worker Fastify service.
- NATS JetStream: Migrates processing queues to NATS JetStream with lazy connection and 120s idle sleep for Railway.
- ONNX PDF Layout: Upgrades to PP-DocLayout-V3 with block overlay UI, force reparse, and block-level skip playback rules.
- ONNX Whisper Alignment: Replaces whisper.cpp with a pure JS ONNX alignment decoder.
- Onboarding Coordinator: Integrates OnboardingFlowContext to manage sequential modals (Privacy, Claim Guest Data, Dexie Cloud Migration).
- Database Schema: Adds documentSettings table and tracks parsing stages (parseState, parsedJsonKey) in the documents table.
- Error Contracts: Standardizes API endpoints on ServerAppError responses.
This commit is contained in:
Richard R 2026-05-27 18:00:01 -06:00 committed by GitHub
commit 218e248cc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
234 changed files with 27768 additions and 2131 deletions

View file

@ -1,4 +1,10 @@
# Logging
# pretty (default): human-readable logs
# json: structured logs for log platforms
# LOG_FORMAT=pretty
# LOG_LEVEL=info
# Local / OpenAI TTS API Configuration (default)
# Suggest using https://github.com/remsky/Kokoro-FastAPI
#
@ -77,8 +83,39 @@ 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
# Compute
# Embedded/local (default): leave COMPUTE_WORKER_URL empty.
# External worker: set COMPUTE_WORKER_URL + COMPUTE_WORKER_TOKEN.
# External worker split:
# - App side (this root `.env`): set only routing/auth + shared timeout/stale overrides.
# - Worker side (`compute/worker/.env*` or worker platform env): set NATS_*, S3_*, model base URLs, and worker tuning.
# Details: docs/deploy/compute-worker
# COMPUTE_WORKER_URL=http://localhost:8081
# COMPUTE_WORKER_TOKEN=local-compute-token
# 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` here is for embedded startup only. In external worker mode, set `NATS_URL` on the worker service env.
# NATS_URL=nats://127.0.0.1:4222
# Optional worker log level:
# COMPUTE_LOG_LEVEL=info
# Optional shared compute tuning:
# COMPUTE_JOB_CONCURRENCY=1
# COMPUTE_WHISPER_TIMEOUT_MS=30000
# COMPUTE_PDF_TIMEOUT_MS=300000
# 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)
# 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=
@ -86,13 +123,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
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=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

View file

@ -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<<EOF" >> "$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 }}"

View file

@ -34,8 +34,19 @@ 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.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
- name: Build app and enforce bundle guard
run: |
pnpm build
pnpm build:bundle-guard
- name: Verify ffprobe
run: ffprobe -version
- name: Install Playwright Browsers

2
.gitignore vendored
View file

@ -33,7 +33,9 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env
.env.prod
.dockerignore
*.creds
# vercel
.vercel

View file

@ -1,24 +1,14 @@
# 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
# 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 && \
(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
@ -29,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
@ -59,29 +51,30 @@ 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
# Install pnpm for runtime process commands.
RUN npm install -g pnpm@10.33.4
# App runtime directory
WORKDIR /app
# Copy built app and dependencies from the builder stage
# Copy built app and runtime files from the builder stage (non-standalone runtime).
COPY --from=app-builder /app ./
# 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 --from=app-builder /app/compute/core/src/pdf/assets/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 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
# 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 --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
# Expose the port the app runs on
EXPOSE 3003

View file

@ -18,13 +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 optional `whisper.cpp` timestamps.
- 🛜 **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 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.
- 🐳 **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
@ -32,6 +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 (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) |

21
compute/core/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"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",
"./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"
}
}

View file

@ -0,0 +1,120 @@
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 WhisperAlignJobBase {
text: string;
lang?: string;
cacheKey?: string;
}
export interface WhisperAlignJobRequest extends WhisperAlignJobBase {
audioObjectKey: string;
}
export interface WhisperAlignJobResult {
alignments: TTSSentenceAlignment[];
timing?: WorkerJobTiming;
}
export interface PdfLayoutJobBase {
documentId: string;
namespace: string | null;
}
export interface PdfLayoutJobRequest extends PdfLayoutJobBase {
documentObjectKey: string;
}
export type PdfLayoutJobResult =
| {
parsed: ParsedPdfDocument;
parsedObjectKey?: never;
timing?: WorkerJobTiming;
}
| {
parsed?: never;
parsedObjectKey: string;
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 type PdfLayoutProgressPhase = 'infer' | 'merge';
export interface PdfLayoutProgress {
totalPages: number;
pagesParsed: number;
currentPage?: number;
phase: PdfLayoutProgressPhase;
}
export interface WorkerJobStatusResponse<Result> {
status: WorkerJobState;
result?: Result;
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<Result = unknown> {
opId: string;
opKey: string;
kind: WorkerOperationKind;
jobId: string;
status: WorkerJobState;
queuedAt: number;
updatedAt: number;
startedAt?: number;
result?: Result;
error?: WorkerJobErrorShape;
timing?: WorkerJobTiming;
progress?: PdfLayoutProgress;
}
export interface WorkerOperationEvent<Result = unknown> {
eventId: number;
snapshot: WorkerOperationState<Result>;
}

View file

@ -0,0 +1,28 @@
import os from 'node:os';
function readPositiveInt(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);
}
export function getComputeJobConcurrency(): number {
return readPositiveInt('COMPUTE_JOB_CONCURRENCY', 1);
}
export function getAvailableCpuCores(): number {
if (typeof os.availableParallelism === 'function') {
const value = os.availableParallelism();
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
}
const fallback = os.cpus().length;
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
}
export function getOnnxThreadsPerJob(): number {
const concurrency = getComputeJobConcurrency();
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
return Math.max(1, Math.floor(usableCores / concurrency));
}

View file

@ -0,0 +1,119 @@
const DEFAULT_COMPUTE_WHISPER_TIMEOUT_MS = 30_000;
const DEFAULT_COMPUTE_PDF_TIMEOUT_MS = 300_000;
const DEFAULT_COMPUTE_PDF_HARD_CAP_MS = 24 * 60 * 60 * 1000;
const DEFAULT_COMPUTE_OP_STALE_MIN_MS = 30 * 60_000;
const DEFAULT_WORKER_WAIT_BUFFER_MS = 15_000;
const DEFAULT_WORKER_WAIT_MIN_MS = 60_000;
export type ComputeTimeoutConfig = {
whisperTimeoutMs: number;
pdfTimeoutMs: number;
pdfHardCapMs: number;
};
export type ComputeOperationKind = 'whisper_align' | 'pdf_layout';
export type IdleTimeoutAndHardCapInput<T> = {
run: (touchProgress: () => void) => Promise<T>;
idleTimeoutMs: number;
hardCapMs: number;
label: string;
};
function readPositiveIntEnv(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);
}
let timeoutConfigCache: ComputeTimeoutConfig | null = null;
let opStaleMsCache: number | null = null;
export function getComputeTimeoutConfig(): ComputeTimeoutConfig {
if (timeoutConfigCache) return timeoutConfigCache;
timeoutConfigCache = {
whisperTimeoutMs: readPositiveIntEnv('COMPUTE_WHISPER_TIMEOUT_MS', DEFAULT_COMPUTE_WHISPER_TIMEOUT_MS),
pdfTimeoutMs: readPositiveIntEnv('COMPUTE_PDF_TIMEOUT_MS', DEFAULT_COMPUTE_PDF_TIMEOUT_MS),
pdfHardCapMs: DEFAULT_COMPUTE_PDF_HARD_CAP_MS,
};
return timeoutConfigCache;
}
export function getComputeOpStaleMs(): number {
if (typeof opStaleMsCache === 'number') return opStaleMsCache;
const config = getComputeTimeoutConfig();
opStaleMsCache = readPositiveIntEnv(
'COMPUTE_OP_STALE_MS',
Math.max(DEFAULT_COMPUTE_OP_STALE_MIN_MS, Math.max(config.whisperTimeoutMs, config.pdfTimeoutMs) * 4),
);
return opStaleMsCache;
}
export function getWorkerClientWaitTimeoutMs(kind: ComputeOperationKind): number {
const config = getComputeTimeoutConfig();
const timeoutMs = kind === 'pdf_layout' ? config.pdfTimeoutMs : config.whisperTimeoutMs;
return Math.max(DEFAULT_WORKER_WAIT_MIN_MS, timeoutMs + DEFAULT_WORKER_WAIT_BUFFER_MS);
}
export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function withIdleTimeoutAndHardCap<T>(input: IdleTimeoutAndHardCapInput<T>): Promise<T> {
let idleTimer: NodeJS.Timeout | null = null;
let hardCapTimer: NodeJS.Timeout | null = null;
let settled = false;
let rejectTimeout!: (reason: unknown) => void;
const clearTimers = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (hardCapTimer) {
clearTimeout(hardCapTimer);
hardCapTimer = null;
}
};
const failTimeout = (kind: 'idle' | 'hard cap', timeoutMs: number) => {
if (settled) return;
settled = true;
clearTimers();
rejectTimeout(new Error(`${input.label} ${kind} timed out after ${timeoutMs}ms`));
};
const touchProgress = () => {
if (settled) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => failTimeout('idle', input.idleTimeoutMs), input.idleTimeoutMs);
};
const timeoutPromise = new Promise<never>((_, reject) => {
rejectTimeout = reject;
hardCapTimer = setTimeout(() => failTimeout('hard cap', input.hardCapMs), input.hardCapMs);
touchProgress();
});
try {
const result = await Promise.race([input.run(touchProgress), timeoutPromise]);
settled = true;
clearTimers();
return result as T;
} catch (error) {
settled = true;
clearTimers();
throw error;
}
}

View file

@ -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<WorkerOperationKind, QueuedOperation[]>();
constructor() {
this.byKind.set('whisper_align', []);
this.byKind.set('pdf_layout', []);
}
async enqueue(job: QueuedOperation): Promise<void> {
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<QueuedOperation | null> {
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<string, OperationState>();
private readonly opIndexByKey = new Map<string, string>();
async getOpState(opId: string): Promise<OperationState | null> {
return this.stateByOpId.get(opId) ?? null;
}
async putOpState(state: OperationState): Promise<void> {
this.stateByOpId.set(state.opId, state);
}
async getOpIndex(opKey: string): Promise<OperationIndexEntry | null> {
const opId = this.opIndexByKey.get(opKey);
return opId ? { opId } : null;
}
async compareAndSetOpIndex(input: {
opKey: string;
newOpId: string;
expectedOpId: string | null;
}): Promise<boolean> {
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<string, number>();
private readonly eventsByOpId = new Map<string, OperationEvent[]>();
constructor() {
this.emitter.setMaxListeners(0);
}
async append(opId: string, snapshot: OperationState): Promise<OperationEvent> {
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<OperationEvent[]> {
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<void>;
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);
};
}
}

View file

@ -0,0 +1,5 @@
export * from './types';
export * from './state-machine';
export * from './orchestrator';
export * from './in-memory';
export * from './sse';

View file

@ -0,0 +1,288 @@
import { randomUUID } from 'node:crypto';
import type {
WorkerJobErrorShape,
WorkerJobTiming,
WorkerOperationKind,
WorkerOperationRequest,
WorkerOperationState,
} from '../api-contracts';
import {
buildQueuedState,
createErrorShape,
explainReplacementReason,
isTerminalStatus,
shouldReuseExistingOperation,
} from './state-machine';
import type {
OperationClock,
OperationEventStream,
OperationIdFactory,
OperationLifecycleConfig,
OperationQueue,
OperationStateStore,
QueuedOperation,
} from './types';
const RETRY_DELAY_MS = 25;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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<void> {
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<WorkerOperationState> {
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();
if (shouldReuseExistingOperation({
current,
requestKind: request.kind,
now,
opStaleMs: this.opStaleMs,
})) {
return current;
}
const replacement = buildQueuedState({
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 = buildQueuedState({
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<WorkerOperationState | null> {
return this.stateStore.getOpState(opId);
}
async markRunning(input: {
opId: string;
startedAt?: number;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<WorkerOperationState> {
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<WorkerOperationState> {
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<WorkerOperationState> {
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<WorkerOperationState> {
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<string> {
return explainReplacementReason({
current: input.current,
requestKind: input.requestKind,
now: this.clock.now(),
opStaleMs: this.opStaleMs,
});
}
private async requireState(opId: string): Promise<WorkerOperationState> {
const current = await this.stateStore.getOpState(opId);
if (!current) {
throw new Error(`Operation not found: ${opId}`);
}
if (isTerminalStatus(current.status)) {
return current;
}
return current;
}
}

View file

@ -0,0 +1,49 @@
export interface SseFrameInput<T = unknown> {
event?: string;
id?: string | number;
data?: T;
comment?: string;
}
export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): 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;
}

View file

@ -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;
}

View file

@ -0,0 +1,63 @@
import type {
WorkerOperationEvent,
WorkerOperationKind,
WorkerOperationState,
} from '../api-contracts';
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
export interface QueuedOperation<TPayload = unknown> {
jobId: string;
opId: string;
opKey: string;
kind: WorkerOperationKind;
queuedAt: number;
payload: TPayload;
}
export interface OperationQueue<TPayload = unknown> {
enqueue(job: QueuedOperation<TPayload>): Promise<void>;
claimNext(kind: WorkerOperationKind): Promise<QueuedOperation<TPayload> | null>;
size(kind?: WorkerOperationKind): number;
}
export interface OperationIndexEntry {
opId: string;
}
export interface OperationStateStore<Result = unknown> {
getOpState(opId: string): Promise<OperationState<Result> | null>;
putOpState(state: OperationState<Result>): Promise<void>;
getOpIndex(opKey: string): Promise<OperationIndexEntry | null>;
compareAndSetOpIndex(input: {
opKey: string;
newOpId: string;
expectedOpId: string | null;
}): Promise<boolean>;
}
export interface OperationEventStream<Result = unknown> {
append(opId: string, snapshot: OperationState<Result>): Promise<OperationEvent<Result>>;
listSince(opId: string, sinceEventId: number, limit?: number): Promise<OperationEvent<Result>[]>;
subscribe(input: {
opId: string;
sinceEventId?: number;
onEvent: (event: OperationEvent<Result>) => void | Promise<void>;
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;
}

24
compute/core/src/index.ts Normal file
View file

@ -0,0 +1,24 @@
export * from './api-contracts';
export {
getComputeJobConcurrency,
getAvailableCpuCores,
getOnnxThreadsPerJob,
} from './config/cpu-budget';
export {
getComputeTimeoutConfig,
getComputeOpStaleMs,
getWorkerClientWaitTimeoutMs,
withTimeout,
withIdleTimeoutAndHardCap,
type ComputeTimeoutConfig,
type ComputeOperationKind,
type IdleTimeoutAndHardCapInput,
} from './config/timeout';
export { renderPage } from './pdf/render';
export { mergeTextWithRegions } from './pdf/merge';
export { stitchCrossPageBlocks } from './pdf/stitch';
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';

View file

@ -0,0 +1,40 @@
import { ensureWhisperModel } from './whisper/model';
import { alignAudioWithText } from './whisper/align';
import { ensureModel as ensurePdfLayoutModel } from './pdf/model';
import { parsePdf } from './pdf/parse';
export async function ensureComputeModels(): Promise<void> {
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;
onPageParsed?: (input: {
pageNumber: number;
totalPages: number;
pageMs: number;
}) => void | Promise<void>;
}) {
const parsed = await parsePdf({
documentId: input.documentId,
pdfBytes: input.pdfBytes,
onPageParsed: input.onPageParsed,
});
return { parsed };
}

View file

@ -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

View file

@ -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
}
]
}

View file

@ -0,0 +1,153 @@
import type { LayoutRegion, PdfTextItem } from './types';
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([
'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<PdfTextItem, number>();
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<number, PdfTextItem[]>();
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;
}

View file

@ -0,0 +1,147 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
import { DOCSTORE_DIR } from '../platform/docstore';
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 MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model');
const MANIFEST_PATH = path.join(MODULE_DIR, 'assets', 'manifest.json');
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(MODULE_DIR, 'assets', 'LICENSE.txt');
type ManifestEntry = {
path: string;
sha256?: string;
size?: number;
};
function loadManifest(): { files: ManifestEntry[] } {
const manifestText = readFileSync(MANIFEST_PATH, 'utf8');
const parsed = JSON.parse(manifestText) as { files?: ManifestEntry[] };
return { files: Array.isArray(parsed.files) ? parsed.files : [] };
}
const manifest = loadManifest();
let inflight: Promise<string> | null = null;
async function sha256Hex(filePath: string): Promise<string> {
const bytes = await readFile(filePath);
return createHash('sha256').update(bytes).digest('hex');
}
async function downloadToFile(url: string, outPath: string): Promise<void> {
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<boolean> {
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<void> {
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<string> {
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<string> {
if (inflight) return inflight;
inflight = ensureModelInternal().finally(() => {
inflight = null;
});
return inflight;
}

View file

@ -0,0 +1,35 @@
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { PdfTextItem } from './types';
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,
};
});
}

View file

@ -0,0 +1,150 @@
import path from 'path';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf';
import { ensureModel } from './model';
import { runLayoutModel } from './runLayoutModel';
import { mergeTextWithRegions } from './merge';
import { stitchCrossPageBlocks } from './stitch';
import { renderPage } from './render';
import { normalizeTextItemsForLayout } from './normalize-text';
interface ParsePdfInput {
documentId: string;
pdfBytes: ArrayBuffer;
onPageParsed?: (input: {
pageNumber: number;
totalPages: number;
pageMs: number;
}) => void | Promise<void>;
}
const LAYOUT_RENDER_SCALE = 1.5;
function resolvePdfjsStandardFontDataUrl(): string {
const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts');
return `${standardFontDir.replace(/\/?$/, '/')}`;
}
export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument> {
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 standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
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 pageStartedAt = Date.now();
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 (input.onPageParsed) {
await input.onPageParsed({
pageNumber,
totalPages: pdf.numPages,
pageMs: Date.now() - pageStartedAt,
});
}
}
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);
}
}

View file

@ -0,0 +1,166 @@
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<CanvasRuntime> | null = null;
function resolvePdfjsStandardFontDataUrl(): string {
const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts');
return `${standardFontDir.replace(/\/?$/, '/')}`;
}
async function loadCanvasRuntime(): Promise<CanvasRuntime> {
if (!canvasRuntimePromise) {
canvasRuntimePromise = (async () => {
const mod = await import('@napi-rs/canvas');
const namespace = mod as Record<string, unknown>;
const fallback = (namespace.default ?? {}) as Record<string, unknown>;
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<string, unknown>;
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 standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
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);
}
}

View file

@ -0,0 +1,339 @@
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 './model';
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
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<Record<LayoutRegion['label'], number>> = {
header: 0.4,
footer: 0.4,
figure_title: 0.45,
footnote: 0.45,
vision_footnote: 0.45,
};
const LABEL_MAP: Record<string, LayoutRegion['label'] | null> = {
// 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<Record<LayoutRegion['label'], { minWidth: number; minHeight: number }>> = {
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<ort.InferenceSession> | null = null;
let idToLabelPromise: Promise<Record<number, string>> | null = null;
let preprocessorPromise: Promise<ModelPreprocessor> | 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<string, unknown>;
const fallback = (namespace.default ?? {}) as Record<string, unknown>;
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<ort.InferenceSession> {
if (!sessionPromise) {
sessionPromise = (async () => {
const modelPath = await ensureModel();
const onnxThreadsPerJob = getOnnxThreadsPerJob();
const stableSessionOptions: ort.InferenceSession.SessionOptions = {
executionProviders: ['cpu'],
graphOptimizationLevel: 'all',
intraOpNumThreads: onnxThreadsPerJob,
interOpNumThreads: 1,
executionMode: 'sequential',
};
return ort.InferenceSession.create(modelPath, {
...stableSessionOptions,
});
})();
}
return sessionPromise;
}
async function getIdToLabel(): Promise<Record<number, string>> {
if (!idToLabelPromise) {
idToLabelPromise = (async () => {
await ensureModel();
const raw = await readFile(MODEL_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw) as { id2label?: Record<string, string> };
const out: Record<number, string> = {};
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<ModelPreprocessor> {
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<LayoutRegion[]> {
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)}`,
);
}
}

View file

@ -0,0 +1,144 @@
import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf';
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
'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<ParsedPdfBlock['kind']>([
'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,
};
}

View file

@ -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;
}

View file

@ -0,0 +1,21 @@
import fs from 'fs';
import path from 'path';
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();

View file

@ -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',
);
}

View file

@ -0,0 +1,17 @@
export type {
TTSAudioBuffer,
TTSAudioBytes,
TTSSentenceAlignment,
TTSSentenceWord,
} from './tts';
export type {
ParsedPdfBlock,
ParsedPdfBlockFragment,
ParsedPdfBlockKind,
ParsedPdfDocument,
ParsedPdfPage,
PdfParsePhase,
PdfParseProgress,
PdfParseStatus,
} from './parsed-pdf';

View file

@ -0,0 +1,63 @@
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[];
}
export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed';
export type PdfParsePhase = 'infer' | 'merge';
export interface PdfParseProgress {
totalPages: number;
pagesParsed: number;
currentPage?: number;
phase: PdfParsePhase;
}

View file

@ -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[];
}

File diff suppressed because it is too large Load diff

View file

@ -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,
};
}

View file

@ -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.

View file

@ -0,0 +1,76 @@
{
"name": "whisper-base_timestamped-q4",
"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_q4.onnx",
"sha256": "0df94e35822653ba16e23c3f19f05b9b7fe7aae97884d1b277e02044f33c4880",
"size": 18771046
},
{
"path": "onnx/decoder_model_merged_q4.onnx",
"sha256": "fc1902ce2e42c69b2346d8e2a98898c60c01da1e6a64ae90f41d22350ac7db13",
"size": 123738327
},
{
"path": "onnx/decoder_with_past_model_q4.onnx",
"sha256": "d864ca26509968b00d92e6823c4db7ac460c106f9228a21bc5199e9893fe4126",
"size": 121378741
},
{
"path": "LICENSE.txt",
"sha256": "b5d65a59060e68c4ff940e1eddfa6f94b2d68fdf58ed7f4dd57721c997e35e9d",
"size": 1063
}
]
}

Binary file not shown.

View file

@ -0,0 +1,249 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
import { DOCSTORE_DIR } from '../platform/docstore';
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, 'assets', 'LICENSE.txt');
const MANIFEST_PATH = path.join(MODULE_DIR, 'assets', 'manifest.json');
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_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';
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_q4.onnx',
'onnx/decoder_model_merged_q4.onnx',
'onnx/decoder_with_past_model_q4.onnx',
];
const DEFAULT_URLS: Record<string, string> = {
'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_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 };
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<Response>;
function loadManifestFiles(): ManifestEntry[] {
const manifestText = readFileSync(MANIFEST_PATH, 'utf8');
const parsed = JSON.parse(manifestText) as { files?: ManifestEntry[] };
return Array.isArray(parsed.files) ? parsed.files : [];
}
const MANIFEST_FILES = loadManifestFiles();
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<boolean> {
const bytes = await readFile(filePath);
return verifyBytes(bytes, expected);
}
async function downloadToFile(fetchImpl: WhisperFetch, url: string, outPath: string): Promise<void> {
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<void> {
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<T>(work: () => Promise<T>): () => Promise<T> {
let inflight: Promise<T> | null = null;
return async () => {
if (inflight) return inflight;
inflight = work().finally(() => {
inflight = null;
});
return inflight;
};
}
async function ensureModelInternal(): Promise<string> {
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<string> {
return ensureWhisperModelSingleflight();
}

View file

@ -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;
}

View file

@ -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<Tokenizer, 'decode'>, tokens: number[]): string {
return tokenizer.decode(tokens, { skip_special_tokens: false });
}
function splitTokensOnUnicode(
tokenizer: Pick<Tokenizer, 'decode'>,
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<Tokenizer, 'decode'>,
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<Tokenizer, 'decode'>,
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<string, ort.Tensor>;
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<Tokenizer, 'decode'>;
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);
}

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,58 @@
# External compute-worker service env only.
# Keep COMPUTE_WORKER_TOKEN and S3_* aligned with app env.
# In external worker mode, worker runtime vars live here (or worker platform env),
# not in the app/root `.env`.
# App/root `.env` should only set:
# - COMPUTE_WORKER_URL
# - COMPUTE_WORKER_TOKEN
# - optional shared timeout/stale overrides
# Details: docs/deploy/compute-worker
# Compute worker bind
# Platform note:
# - Local/manual: keep PORT=8081
# - Railway/Render/Fly/etc: platform injects PORT
# COMPUTE_WORKER_HOST=0.0.0.0
# PORT=8081
# LOG_FORMAT=pretty
# COMPUTE_LOG_LEVEL=info
# Must match app env when app uses COMPUTE_WORKER_URL
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
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
# Optional tuning
# COMPUTE_PREWARM_MODELS=true
# COMPUTE_JOB_CONCURRENCY=1
# COMPUTE_WHISPER_TIMEOUT_MS=30000
# COMPUTE_PDF_TIMEOUT_MS=300000
# COMPUTE_PDF_JOB_ATTEMPTS=1
# COMPUTE_JOBS_STREAM_MAX_BYTES=268435456
# COMPUTE_EVENTS_STREAM_MAX_BYTES=134217728
# COMPUTE_JOB_STATES_MAX_BYTES=67108864
# 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

29
compute/worker/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:lts AS deploy-stage
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
COPY compute/worker compute/worker
COPY compute/core compute/core
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 PORT=8081
EXPOSE 8081
CMD ["node", "--import", "tsx", "src/server.ts"]

View file

@ -0,0 +1,47 @@
services:
nats:
image: nats:2.14-alpine
container_name: openreader-compute-nats
command: ["-js", "-sd", "/data"]
ports:
- "4222:4222"
- "8222:8222"
volumes:
- nats-data:/data
compute-worker:
build:
context: ../..
dockerfile: compute/worker/Dockerfile
container_name: openreader-compute-worker
depends_on:
- nats
env_file:
- ./.env
environment:
NATS_URL: ${NATS_URL:-nats://nats:4222}
COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0}
PORT: ${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
- action: rebuild
path: ../../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
volumes:
nats-data:

View file

@ -0,0 +1,22 @@
{
"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.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:*",
"fastify": "^5.6.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.2",
"tsx": "^4.22.3",
"zod": "^4.1.12"
}
}

View file

@ -0,0 +1,315 @@
import { createHash } from 'node:crypto';
import { AckPolicy, DeliverPolicy, ReplayPolicy, type JetStreamClient, type JetStreamManager } from '@nats-io/jetstream';
import { nanos } from '@nats-io/transport-node';
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';
import { createJsonCodec } from './json-codec';
export interface KvEntryLike {
operation?: string;
value: Uint8Array;
revision: number;
}
export interface KvStoreLike {
get(key: string): Promise<KvEntryLike | null>;
put(key: string, data: Uint8Array): Promise<unknown>;
create(key: string, data: Uint8Array): Promise<unknown>;
update(key: string, data: Uint8Array, version: number): Promise<unknown>;
}
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;
}
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');
}
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<KvStoreLike>;
}
export class JetStreamOperationStateStore<Result = unknown> implements OperationStateStore<Result> {
private readonly getKv: () => Promise<KvStoreLike>;
private readonly opStateCodec = createJsonCodec<OperationState<Result>>();
private readonly opIndexCodec = createJsonCodec<OpIndexEntry>();
constructor(deps: JetStreamOperationStateStoreDeps) {
this.getKv = deps.getKv;
}
async getOpState(opId: string): Promise<OperationState<Result> | 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<Result>): Promise<void> {
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<boolean> {
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<Pick<JetStreamClient, 'publish' | 'consumers'>>;
getJsm: () => Promise<Pick<JetStreamManager, 'consumers'>>;
eventsStreamName: string;
inactiveThresholdMs?: number;
}
export class JetStreamOperationEventStream<Result = unknown> implements OperationEventStream<Result> {
private readonly getJs: () => Promise<Pick<JetStreamClient, 'publish' | 'consumers'>>;
private readonly getJsm: () => Promise<Pick<JetStreamManager, 'consumers'>>;
private readonly eventsStreamName: string;
private readonly inactiveThresholdNanos: number;
private readonly opStateCodec = createJsonCodec<OperationState<Result>>();
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<Result>): Promise<OperationEvent<Result>> {
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,
};
}
private async createConsumer(input: {
opId: string;
sinceEventId?: number;
replayOnly: boolean;
}): Promise<{ name: string; js: Pick<JetStreamClient, 'publish' | 'consumers'> }> {
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 };
}
private async deleteConsumer(name: string): Promise<void> {
const jsm = await this.getJsm();
await jsm.consumers.delete(this.eventsStreamName, name).catch(() => undefined);
}
async listSince(opId: string, sinceEventId: number, limit = 200): Promise<OperationEvent<Result>[]> {
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<Result>[] = [];
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<Result>) => void | Promise<void>;
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);
};
}
}
export interface JetStreamOperationQueueDeps<TPayload> {
getJs: () => Promise<Pick<JetStreamClient, 'publish'>>;
whisperSubject: string;
layoutSubject: string;
onEnqueued?: (job: QueuedOperation<TPayload>) => Promise<void> | void;
}
export class JetStreamOperationQueue implements OperationQueue<WhisperAlignJobRequest | PdfLayoutJobRequest> {
private readonly getJs: () => Promise<Pick<JetStreamClient, 'publish'>>;
private readonly whisperSubject: string;
private readonly layoutSubject: string;
private readonly onEnqueued?: (job: QueuedOperation<WhisperAlignJobRequest | PdfLayoutJobRequest>) => Promise<void> | void;
private readonly whisperCodec = createJsonCodec<QueuedOperation<WhisperAlignJobRequest>>();
private readonly layoutCodec = createJsonCodec<QueuedOperation<PdfLayoutJobRequest>>();
constructor(deps: JetStreamOperationQueueDeps<WhisperAlignJobRequest | PdfLayoutJobRequest>) {
this.getJs = deps.getJs;
this.whisperSubject = deps.whisperSubject;
this.layoutSubject = deps.layoutSubject;
this.onEnqueued = deps.onEnqueued;
}
async enqueue(job: QueuedOperation<WhisperAlignJobRequest | PdfLayoutJobRequest>): Promise<void> {
const js = await this.getJs();
if (job.kind === 'whisper_align') {
await js.publish(
this.whisperSubject,
this.whisperCodec.encode(job as QueuedOperation<WhisperAlignJobRequest>),
);
} else if (job.kind === 'pdf_layout') {
await js.publish(
this.layoutSubject,
this.layoutCodec.encode(job as QueuedOperation<PdfLayoutJobRequest>),
);
} else {
const exhaustive: never = job.kind;
throw new Error(`Unsupported operation kind: ${String(exhaustive)}`);
}
await this.onEnqueued?.(job);
}
async claimNext(_kind: WorkerOperationKind): Promise<QueuedOperation<WhisperAlignJobRequest | PdfLayoutJobRequest> | null> {
throw new Error('JetStreamOperationQueue.claimNext is not used by the worker runtime');
}
size(): number {
return 0;
}
}

View file

@ -0,0 +1,17 @@
export type JsonCodec<T> = {
encode(value: T): Uint8Array;
decode(data: Uint8Array): T;
};
export function createJsonCodec<T>(): JsonCodec<T> {
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;
},
};
}

1322
compute/worker/src/server.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": []
}

View file

@ -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)

View file

@ -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
@ -74,14 +74,15 @@ 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 compute-worker server env configuration, 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.
@ -91,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.

View file

@ -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 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.

View file

@ -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

View file

@ -0,0 +1,221 @@
title: Compute Worker (NATS JetStream)
---
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
The compute worker handles:
- Whisper word alignment operations
- PDF layout parsing operations
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
- 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
Required:
- `COMPUTE_WORKER_TOKEN`: bearer token expected by worker routes
- `NATS_URL`: NATS server connection string (JetStream enabled)
- `S3_BUCKET`
- `S3_REGION`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
> [!IMPORTANT]
> 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.
> In standalone external worker mode:
> - App server env (root `.env` or platform env): `COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`, optional shared timeout/stale overrides.
> - Worker service env (`compute/worker/.env*` or platform env): worker runtime values (`NATS_*`, `S3_*`, model base URLs, worker tuning).
> 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:
- `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`
- `COMPUTE_WORKER_HOST=0.0.0.0`
- `PORT=8081` (local/manual; on Railway platform injects this)
- `LOG_FORMAT=pretty` (default) or `json`
Advanced tuning (usually leave unset unless you need overrides):
- `COMPUTE_PREWARM_MODELS=true`
- `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, 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)
- `COMPUTE_JOB_STATES_MAX_BYTES=67108864` (64MB JetStream KV bucket cap)
- `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
Set on the Next.js app server:
```env
# Local worker example:
# COMPUTE_WORKER_URL=http://localhost:8081
# Cloud worker example (Railway):
COMPUTE_WORKER_URL=https://<railway-worker-domain>
COMPUTE_WORKER_TOKEN=<same-token-as-worker>
# Optional shared timeout overrides (keep equal to worker service values):
# COMPUTE_WHISPER_TIMEOUT_MS=30000
# COMPUTE_PDF_TIMEOUT_MS=300000
# 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. 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:
- Worker: opKey stale replacement window in compute op state.
- App server: stale PDF parse-state healing window (`/api/documents/[id]/parsed*`).
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.
- Non-exposed embedded `weed mini` is not supported with external worker mode.
- Protect `COMPUTE_WORKER_TOKEN` and avoid exposing worker routes publicly without auth.
## Railway sleep & idle behavior
The worker connects to NATS lazily (on the first request needing the queue/KV) and
disconnects after **120s** of full idle — no in-flight request, SSE stream, job, or
queued work. This stops outbound pull polling and keepalive PINGs so Railway can sleep
it; the next inbound request transparently reconnects, re-ensures the stream/consumers
and KV (idempotent), and drains anything pending. No separate mode, no extra env vars,
and the `/ops*` contract is unchanged.
Caveats: inbound HTTP is the wake signal (in OpenReader the app server only enqueues via
`POST /ops`, so this is always satisfied); a continuous external `/health/*` probe keeps
it awake and prevents sleep; and the first request after a cold start re-runs model
prewarm, so it's slower.
## Health endpoints
- `GET /health/live` — liveness; always returns `{ ok: true }`.
- `GET /health/ready` — returns `{ ok: true, natsConnected }`. It does not probe NATS (that
would reconnect and prevent idle sleep); `natsConnected` just reflects the current session.
## Synadia Cloud + Railway Setup (Complete Guide)
Use this end-to-end guide when your queue backend is Synadia Cloud (NGS) and your worker runs on Railway.
### 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 `<name>.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
```
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
Set these in the Railway worker service:
```env
COMPUTE_WORKER_HOST=0.0.0.0
# Local/manual only:
# PORT=8081
# Railway: rely on injected PORT
COMPUTE_WORKER_TOKEN=<long-random-shared-token>
# Optional advanced tuning overrides (defaults shown):
# COMPUTE_PREWARM_MODELS=true
# COMPUTE_JOB_CONCURRENCY=1
# 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
# COMPUTE_JOB_STATES_MAX_BYTES=67108864
# COMPUTE_NATS_REPLICAS=1
NATS_URL=tls://connect.ngs.global:4222
NATS_CREDS="-----BEGIN NATS USER JWT-----
...
------END USER NKEY SEED------"
S3_BUCKET=<bucket>
S3_REGION=<region>
S3_ACCESS_KEY_ID=<key>
S3_SECRET_ACCESS_KEY=<secret>
S3_ENDPOINT=<optional-for-s3-compatible-providers>
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.
- 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).
- `COMPUTE_NATS_REPLICAS` is optional; default is `1`. Valid values are `1`, `3`, `5`.
### 4. Configure the OpenReader app server
Set these env vars on the app server:
```env
COMPUTE_WORKER_URL=https://<railway-worker-domain>
COMPUTE_WORKER_TOKEN=<same-token-as-worker>
```
### 5. Verify health
After deploy, check:
- `GET https://<railway-worker-domain>/health/live`
- `GET https://<railway-worker-domain>/health/ready`

View file

@ -89,6 +89,41 @@ OpenReader currently pins `4.18` in CI and Docker builds while `4.19` compatibil
</details>
<details>
<summary><strong>NATS Server <code>nats-server</code> (required for embedded compute mode)</strong></summary>
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.
<Tabs groupId="local-dev-nats-os">
<TabItem value="macos" label="macOS" default>
```bash
brew install nats-server
nats-server -v
```
</TabItem>
<TabItem value="linux" label="Linux">
```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
```
</TabItem>
</Tabs>
</details>
<details>
<summary><strong>LibreOffice (optional, for DOCX conversion)</strong></summary>
@ -114,50 +149,51 @@ sudo apt install -y libreoffice
</details>
<details>
<summary><strong>whisper.cpp (optional, for word-by-word highlighting)</strong></summary>
<summary><strong>Word-by-word highlighting (optional)</strong></summary>
Install build dependencies:
No extra native Whisper CLI build step is required.
<Tabs groupId="local-dev-whisper-deps-os">
<TabItem value="macos" label="macOS" default>
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` (current defaults expect q4 Whisper files at that base URL).
</details>
<details>
<summary><strong>External compute worker dev stack (optional)</strong></summary>
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:
```bash
brew install cmake
docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch
# or: pnpm compute:dev:watch
```
</TabItem>
<TabItem value="linux" label="Linux">
`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
Run the main app separately on the host:
```bash
# Debian/Ubuntu example
sudo apt update
sudo apt install -y git build-essential cmake
pnpm dev
```
</TabItem>
</Tabs>
For app -> external worker routing, set in root `.env`:
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"
```env
COMPUTE_WORKER_URL=http://localhost:8081
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
```
If you are not on Debian/Ubuntu, install equivalent packages with your distro package manager:
Ownership in external worker mode:
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
- 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 `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting.
:::
Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
For external worker mode, object storage must be shared/reachable by both app and worker services.
</details>
@ -186,6 +222,24 @@ cp .env.example .env
Then edit `.env`.
Default embedded worker flow (no external worker URL):
```env
# Leave COMPUTE_WORKER_URL unset.
# Entry point auto-starts embedded worker+NATS when available.
```
External worker flow:
```env
COMPUTE_WORKER_URL=http://localhost:8081
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
```
Use the same ownership split:
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
Use one of these `.env` mode templates:
<Tabs groupId="local-env-modes">
@ -239,17 +293,35 @@ S3_SECRET_ACCESS_KEY=your-secret-key
# Optional for non-AWS providers:
# S3_ENDPOINT=https://your-s3-compatible-endpoint
# S3_FORCE_PATH_STYLE=true
```
</TabItem>
<TabItem value="worker-mode" label="External Worker Service">
```env
API_BASE=http://host.docker.internal:8880/v1
API_KEY=none
COMPUTE_WORKER_URL=http://localhost:8081
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
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
```
</TabItem>
</Tabs>
:::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
@ -271,6 +343,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.
</TabItem>
<TabItem value="prod" label="Build + Start">

View file

@ -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) 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
`docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime.
@ -35,15 +37,40 @@ BASE_URL=https://your-app.vercel.app
AUTH_SECRET=...
ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
# Heavy compute (required on Vercel in current releases)
COMPUTE_WORKER_URL=https://<railway-worker-domain>
COMPUTE_WORKER_TOKEN=...
# Logging (recommended for Vercel log ingestion)
LOG_FORMAT=json
LOG_LEVEL=info
# 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
```
If you also run an external worker service (for example Railway), set these there too:
- `LOG_FORMAT=json`
- `COMPUTE_LOG_LEVEL=info`
:::note Env vars vs. admin panel (important for Vercel)
`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://<railway-worker-domain>` (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:
@ -58,11 +85,10 @@ 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)
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.
@ -91,8 +117,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.
@ -109,7 +134,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 }
}
}
```
@ -126,4 +151,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. Verify worker-backed word highlighting and PDF parsing.

View file

@ -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
<Tabs groupId="docker-start-mode">
@ -113,7 +119,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.
:::
@ -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 (NATS JetStream)](./deploy/compute-worker).
```bash
docker stop openreader || true && \

View file

@ -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
- 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**
- [**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 optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps
- 🛜 **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

View file

@ -6,19 +6,17 @@ 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
| Variable | Area | Default | When to set |
| --- | --- | --- | --- |
| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) |
| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs; shared by app server + compute worker |
| `LOG_LEVEL` | Runtime logging | `info` | Set app server log level |
| `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 |
| `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 |
@ -38,6 +36,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
| `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting |
| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) |
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only |
| `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
@ -53,11 +52,50 @@ 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 |
| `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 |
| `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_LOG_LEVEL` | Heavy compute backend | `info` | Compute worker log level |
| `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 q4 downloads |
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
| `RUNTIME_SEED_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features |
| `RUNTIME_SEED_ENABLE_DOCX_CONVERSION` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable/disable DOCX conversion UI |
| `RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide destructive delete actions |
| `RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide user TTS providers tab |
| `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 |
| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK |
| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug |
| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI |
## Runtime Logging
### LOG_FORMAT
Controls log output format for server-side Pino loggers.
- Default: `pretty`
- Allowed values: `pretty`, `json`
- Applies to app server and compute worker
- Recommended in production (Vercel + external worker): `json`
### LOG_LEVEL
App server log level.
- Default: `info`
## TTS Provider and Request Behavior
### API_BASE
@ -230,6 +268,8 @@ Switches metadata/auth storage from SQLite to Postgres.
- Set: Postgres mode
- Related docs: [Database](../configure/database)
### Embedded SeaweedFS weed mini config
### USE_EMBEDDED_WEED_MINI
Controls embedded SeaweedFS startup.
@ -252,6 +292,8 @@ Maximum seconds to wait for embedded SeaweedFS startup.
- Default: `20`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3 storage config
### S3_ACCESS_KEY_ID
Access key for S3-compatible storage.
@ -346,12 +388,121 @@ Multiple library roots for server library import.
## Audio Tooling and Alignment
### WHISPER_CPP_BIN
### COMPUTE_WORKER_URL
Absolute path to compiled `whisper.cpp` binary for word-level timestamps.
Base URL for standalone external compute worker mode.
- Example: `/whisper.cpp/build/bin/whisper-cli`
- Required only for optional word-by-word highlighting
- 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.
- App-side only: set on app server/root `.env` (routing target), not worker-only env files.
- Example: `http://localhost:8081`
### COMPUTE_WORKER_TOKEN
Bearer token for compute-worker auth.
- Required for standalone external worker service mode.
- Must match worker service `COMPUTE_WORKER_TOKEN`.
- In embedded startup, entrypoint auto-generates one if unset.
- In external worker mode, set this on both app server/root `.env` and worker service env (`compute/worker/.env*` or platform env).
### 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)
- For embedded startup, this is optional; startup supplies the default value.
- Worker-side only in external mode: set on worker service env, not app/root `.env`.
### COMPUTE_LOG_LEVEL
Compute worker log level.
- Default: `info`
- In standalone mode, set this on the worker service env.
### COMPUTE_JOB_CONCURRENCY
Worker-side shared compute concurrency cap.
- Default: `1`
- Set on the compute-worker service environment
### COMPUTE_WHISPER_TIMEOUT_MS
Shared whisper alignment timeout budget in milliseconds.
- Default: `30000`
- Used by:
- Worker compute whisper runtime
- App server worker-client wait budget (SSE wait timeout)
### COMPUTE_PDF_TIMEOUT_MS
Shared PDF idle-timeout budget in milliseconds.
- Default: `300000` (5 minutes)
- Used by:
- Worker compute PDF runtime (idle timeout)
- App server worker-client wait budget (SSE wait timeout)
### COMPUTE_OP_STALE_MS
Shared stale window in milliseconds.
- Default: `max(30m, 4x max(COMPUTE_WHISPER_TIMEOUT_MS, COMPUTE_PDF_TIMEOUT_MS))`
- Used by:
- 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.
- Keep this value aligned on both app-server and worker service envs.
### PDF_LAYOUT_MODEL_BASE_URL
Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`.
- 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`
- Configure this on the worker service env (not only the app server env)
### WHISPER_MODEL_BASE_URL
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: 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)
### FFMPEG_BIN
@ -364,23 +515,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.
@ -388,7 +539,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.
@ -397,7 +548,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.
@ -406,7 +557,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.
@ -416,7 +567,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.
@ -425,21 +576,10 @@ 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.
- 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`

View file

@ -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, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) 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 by compute worker runtime + app/worker contracts
- ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
- 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`
- **`@openreader/compute-worker`** — standalone Node.js worker service
- HTTP server: [Fastify](https://fastify.dev/) v5
- 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/)
- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS)
## 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/`)

View file

@ -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',

View file

@ -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

View file

@ -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");

View file

@ -0,0 +1,2 @@
ALTER TABLE "document_previews" ALTER COLUMN "variant" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "document_previews" ALTER COLUMN "width" DROP DEFAULT;

View file

@ -0,0 +1,2 @@
ALTER TABLE "documents" ADD COLUMN "parse_state" text;--> statement-breakpoint
ALTER TABLE "documents" DROP COLUMN "parse_status";

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,27 @@
"when": 1778644075378,
"tag": "0004_admin_panel",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779041718214,
"tag": "0005_pdf_layout_and_compute",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1779102950715,
"tag": "0006_preview-defaults-cleanup",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1779378127846,
"tag": "0007_pdf_parse_progress",
"breakpoints": true
}
]
}

View file

@ -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;

View file

@ -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`);

View file

@ -0,0 +1,2 @@
ALTER TABLE `documents` ADD `parse_state` text;--> statement-breakpoint
ALTER TABLE `documents` DROP COLUMN `parse_status`;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,27 @@
"when": 1778644075081,
"tag": "0004_admin_panel",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1779041717890,
"tag": "0005_pdf_layout_and_compute",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1779102950385,
"tag": "0006_preview-defaults-cleanup",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1779378125905,
"tag": "0007_pdf_parse_progress",
"breakpoints": true
}
]
}

View file

@ -1 +1,3 @@
export default {};
const emptyModule = {};
export default emptyModule;

View file

@ -9,8 +9,114 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
const LOGGER_LEVEL_METHOD = "trace|debug|info|warn|error|fatal";
const STATIC_LOGGER_CALL_SELECTOR =
`CallExpression[callee.type='MemberExpression'][callee.computed=false][callee.property.name=/^(${LOGGER_LEVEL_METHOD})$/]`;
const DYNAMIC_LOGGER_CALL_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.computed=true]";
const LOGGER_RECEIVER_SELECTOR =
":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])";
const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`;
const NEXT_RESPONSE_ERROR_JSON_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])";
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"@openreader/compute-core/*",
"!@openreader/compute-core/local-runtime",
"!@openreader/compute-core/types",
"!@openreader/compute-core/api-contracts",
],
message:
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
},
],
},
],
},
},
{
files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"],
rules: {
"no-console": "error",
"no-restricted-syntax": [
"error",
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.length<2]`,
message:
"Server logger calls must pass context + message: logger.<level>({ event, ...ctx }, 'message').",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type!='ObjectExpression']`,
message:
"Server logger first argument must be an object with an event field.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='Literal']`,
message:
"Server logger first argument must be an object with an event field, not a string literal.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='TemplateLiteral']`,
message:
"Server logger first argument must be an object with an event field, not a template string.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(Property[key.name='event']))`,
message:
"Server logger context object must include an event field.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.1.type!='Literal'][arguments.1.type!='TemplateLiteral']`,
message:
"Server logger second argument must be a message string (literal or template literal).",
},
{
selector: `${STATIC_LOGGER_CALL_SELECTOR}[callee.property.name='error']${LOGGER_RECEIVER_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(ObjectExpression > Property[key.name='error'])):not(:has(ObjectExpression > SpreadElement))`,
message:
"Error-level server logger calls must include nested `error` payload (prefer `error: errorToLog(...)`).",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='detail']`,
message:
"Do not use top-level `detail` in server logger context; keep throwable text under `error.message`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='errorCode']`,
message:
"Do not use top-level `errorCode` in server logger context; classify failures under nested `error.code`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`,
message:
"Use `error` (typically from errorToLog(...)) instead of `err` in server logs.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='Literal']`,
message:
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not a literal.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='TemplateLiteral']`,
message:
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.",
},
{
selector: `CatchClause ReturnStatement > ${NEXT_RESPONSE_ERROR_JSON_SELECTOR}[arguments.1.type='ObjectExpression']:has(Property[key.name='status'][value.value=500])`,
message:
"Use shared error response helpers (e.g. errorResponse(...)) for terminal 500 route failures instead of direct NextResponse.json({ error }).",
},
],
},
},
];
export default eslintConfig;

View file

@ -15,6 +15,14 @@ const securityHeaders = [
},
];
const bundleWorkerCompute = true;
const serverExternalPackages = [
'@napi-rs/canvas',
'better-sqlite3',
'ffmpeg-static',
...(!bundleWorkerCompute ? ['onnxruntime-node', '@huggingface/tokenizers'] : []),
];
const nextConfig: NextConfig = {
async headers() {
return [
@ -30,7 +38,8 @@ const nextConfig: NextConfig = {
canvas: '@napi-rs/canvas',
},
},
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
transpilePackages: [],
serverExternalPackages,
outputFileTracingIncludes: {
'/api/audiobook': [
'./node_modules/ffmpeg-static/ffmpeg',
@ -38,7 +47,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': [
@ -51,6 +60,24 @@ const nextConfig: NextConfig = {
'./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
],
},
outputFileTracingExcludes: {
'/*': [
'./docstore/**/*',
'./node_modules/onnxruntime-node/**/*',
'./node_modules/@huggingface/tokenizers/**/*',
],
},
webpack: (config, { isServer }) => {
if (isServer && bundleWorkerCompute) {
config.resolve.alias = {
...(config.resolve.alias || {}),
'@openreader/compute-core/local-runtime$': false,
'onnxruntime-node$': false,
'@huggingface/tokenizers$': false,
};
}
return config;
},
};
export default nextConfig;

View file

@ -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",
@ -20,12 +21,18 @@
"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",
"lint:route-errors": "node scripts/check-route-error-responses.mjs"
},
"dependencies": {
"@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",
@ -48,9 +55,12 @@
"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",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "^19.2.6",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",

View file

@ -6,6 +6,7 @@ import 'dotenv/config';
*/
export default defineConfig({
testDir: './tests',
tsconfig: './tsconfig.json',
timeout: 30 * 1000,
outputDir: './tests/results',
globalTeardown: './tests/global-teardown.ts',
@ -29,7 +30,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
// Disable auth rate limiting for tests to support parallel workers creating sessions
command: `npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start`,
command: `pnpm build && DISABLE_AUTH_RATE_LIMIT=true pnpm start`,
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
packages:
- .
strictDepBuilds: false
- compute/*
allowBuilds:
'@napi-rs/canvas': true
@ -11,10 +10,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

View file

@ -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');

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
const API_DIR = path.join(ROOT, 'src', 'app', 'api');
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walk(full));
continue;
}
if (entry.isFile() && full.endsWith('.ts')) files.push(full);
}
return files;
}
function findViolations(source) {
const violations = [];
const catchWithDirectJson500 =
/catch\s*\(\s*error\s*\)\s*\{[\s\S]{0,1200}?NextResponse\.json\s*\(\s*\{\s*error:[\s\S]{0,240}status:\s*500/g;
let match;
while ((match = catchWithDirectJson500.exec(source)) !== null) {
const index = match.index;
const line = source.slice(0, index).split('\n').length;
violations.push(line);
}
return violations;
}
const files = await walk(API_DIR);
const failures = [];
for (const file of files) {
const source = await fs.readFile(file, 'utf8');
const lines = findViolations(source);
if (lines.length > 0) {
failures.push({ file, lines });
}
}
if (failures.length > 0) {
for (const failure of failures) {
for (const line of failure.lines) {
console.error(`${path.relative(ROOT, failure.file)}:${line} direct NextResponse.json({ error }) in catch`);
}
}
process.exit(1);
}
console.log('Route error response check passed.');

View file

@ -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;
@ -181,19 +187,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 });
});
});
@ -311,12 +318,23 @@ async function main() {
}
const runtimeEnv = { ...process.env };
runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty');
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 isShuttingDown = false;
let fatalExitScheduled = false;
let stopWeedStdoutForward = () => { };
let stopWeedStderrForward = () => { };
let stopNatsStdoutForward = () => { };
let stopNatsStderrForward = () => { };
let stopWorkerStdoutForward = () => { };
let stopWorkerStderrForward = () => { };
let didExit = false;
const exitOnce = (code) => {
@ -325,16 +343,38 @@ 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),
terminateChild(workerProc, 'SIGTERM', 4000),
terminateChild(natsProc, 'SIGTERM', 4000),
terminateChild(weedProc, 'SIGTERM', 4000),
]);
await weedExitPromise;
await natsExitPromise;
await workerExitPromise;
stopWeedStdoutForward();
stopWeedStderrForward();
stopNatsStdoutForward();
stopNatsStderrForward();
stopWorkerStdoutForward();
stopWorkerStderrForward();
})();
return shutdownPromise;
};
@ -374,7 +414,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');
@ -389,13 +434,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');
});
};
@ -419,9 +464,108 @@ 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 = !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim());
if (shouldStartEmbeddedWorker && !hasNatsBinary()) {
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) {
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 (isShuttingDown) return;
if (typeof code === 'number' && code !== 0) {
console.error(`Embedded nats-server exited with code ${code}.`);
} 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}`);
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',
},
);
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 (isShuttingDown) return;
if (typeof code === 'number' && code !== 0) {
console.error(`Embedded compute-worker exited with code ${code}.`);
} 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}`);
} 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 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);

View file

@ -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 (
<ConfigProvider>
<DocumentProvider>
<>
{children}
<DexieMigrationModal />
</>
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
</DocumentProvider>
</ConfigProvider>
);

View file

@ -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 { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() {
@ -102,6 +103,8 @@ export default function EPUBPage() {
loadDocument();
}, [loadDocument, isLoading]);
useUnmountCleanupRef(clearCurrDoc);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@ -111,7 +114,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 +127,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(() => {
@ -162,7 +173,6 @@ export default function EPUBPage() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/app"
onClick={() => clearCurrDoc()}
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"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -180,7 +190,6 @@ export default function EPUBPage() {
left={
<Link
href="/app"
onClick={() => clearCurrDoc()}
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"
>

View file

@ -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:

View file

@ -17,6 +17,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { useHtmlDocument } from './useHtmlDocument';
@ -102,6 +103,8 @@ export default function HTMLPage() {
loadDocument();
}, [loadDocument, isLoading]);
useUnmountCleanupRef(clearCurrDoc);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@ -111,7 +114,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 +126,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,
@ -149,7 +160,6 @@ export default function HTMLPage() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/app"
onClick={() => { clearCurrDoc(); }}
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"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -167,7 +177,6 @@ export default function HTMLPage() {
left={
<Link
href="/app"
onClick={() => clearCurrDoc()}
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"
>

View file

@ -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(

View file

@ -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}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">{children}</main>
</div>
<Toaster

View file

@ -3,14 +3,14 @@
import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/documents/DocumentSettings';
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 +19,14 @@ 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 {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
FORCE_REPARSE_CONFIRM_TITLE,
isForceReparseDisabled,
} from '@/lib/client/pdf/force-reparse';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { usePdfDocument } from './usePdfDocument';
// Dynamic import for client-side rendering only
@ -26,10 +34,12 @@ const PDFViewer = dynamic(
() => import('@/components/views/PDFViewer').then((module) => module.PDFViewer),
{
ssr: false,
loading: () => <DocumentSkeleton />
loading: () => null
}
);
const PARSE_LOADER_EXPAND_DELAY_MS = 320;
export default function PDFViewerPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
@ -41,6 +51,13 @@ export default function PDFViewerPage() {
clearCurrDoc,
currDocPage,
currDocPages,
parseStatus,
parseProgress,
documentSettings,
updateDocumentSettings,
parsedOverlayEnabled,
setParsedOverlayEnabled,
forceReparseParsedPdf,
createFullAudioBook: createPDFAudioBook,
regenerateChapter: regeneratePDFChapter,
} = pdfState;
@ -48,14 +65,29 @@ export default function PDFViewerPage() {
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPdfViewerReady, setIsPdfViewerReady] = useState(false);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false);
const [showDetailedParseLoader, setShowDetailedParseLoader] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
const [isNavigatingBack, setIsNavigatingBack] = useState(false);
const parseUiState: NonNullable<typeof parseStatus> = parseStatus ?? 'pending';
const hasResolvedParseStatus = parseStatus !== null;
const isParseReady = parseUiState === 'ready';
const forceReparseDisabled = isForceReparseDisabled(parseStatus);
const hasRealParseProgress = !!parseProgress
&& parseProgress.totalPages > 0
&& parseProgress.pagesParsed >= 0;
const shouldShowExpandedParseLoader = !isLoading
&& hasResolvedParseStatus
&& (parseUiState === 'pending' || parseUiState === 'running' || parseUiState === 'failed' || hasRealParseProgress);
useEffect(() => {
setIsLoading(true);
setIsPdfViewerReady(false);
setError(null);
setActiveSidebar(null);
inFlightDocIdRef.current = null;
@ -67,6 +99,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');
@ -89,8 +122,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');
@ -98,7 +143,7 @@ export default function PDFViewerPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
if (!didRedirect && startedLoad && loadSucceeded) {
setIsLoading(false);
}
}
@ -108,6 +153,30 @@ export default function PDFViewerPage() {
loadDocument();
}, [loadDocument]);
useUnmountCleanupRef(clearCurrDoc);
useEffect(() => {
if (isLoading) return;
if (isParseReady) return;
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 = () => {
@ -117,24 +186,55 @@ 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));
const handleBackToDocuments = useCallback((event?: MouseEvent) => {
event?.preventDefault();
if (isNavigatingBack) return;
setIsNavigatingBack(true);
stop();
setActiveSidebar(null);
router.push('/app');
}, [isNavigatingBack, stop, 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,
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,
@ -142,8 +242,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 (
@ -151,7 +254,7 @@ export default function PDFViewerPage() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/app"
onClick={() => { 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"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -163,13 +266,124 @@ export default function PDFViewerPage() {
);
}
const renderPdfStatusLoader = () => {
const compactLabel = isLoading
? 'Opening PDF...'
: (parseUiState === 'ready' ? 'Rendering pages...' : 'Preparing PDF layout...');
const compactSubLabel = isLoading
? 'Loading document data'
: (parseUiState === 'ready' ? 'Preparing first frame' : 'Queueing parser and preparing page extraction');
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 (parseUiState === 'pending') {
statusText = 'Preparing PDF layout...';
statusSubText = parseProgress?.phase === 'merge'
? 'Finalizing stitched block structure'
: 'Queueing parser and preparing page extraction';
} 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 (parseUiState === 'failed') {
statusText = 'PDF parsing failed. Retry to continue.';
statusSubText = 'The parser could not build a usable layout map';
}
}
const stageLabel = parseUiState === 'failed'
? 'Stage: blocked'
: (parseUiState === 'pending'
? 'Stage: prepare'
: (isMerging ? 'Stage: merge' : 'Stage: infer'));
return (
<div className="h-full w-full bg-base">
<div className={`mx-auto flex h-full items-center px-4 py-6 transition-all duration-300 ease-out ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
{showDetailedParseLoader ? (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 shadow-sm overflow-hidden">
<div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" />
<div className="p-3.5 sm:p-4">
<div className="space-y-1.5">
<div className="inline-flex items-center gap-2 rounded-md border border-offbase bg-base/70 px-2.5 py-1">
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted">PDF Layout Parse</span>
</div>
<p className="text-sm font-semibold text-foreground">{statusText}</p>
</div>
<div className="mt-3 rounded-lg border border-offbase bg-base/75 p-2.5">
<div className="mb-1.5 flex items-end justify-between gap-2">
<p className="text-[11px] font-semibold text-foreground">
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
</p>
<p className="text-[10px] text-muted">{stageLabel}</p>
</div>
<div className="h-2 w-full rounded-full bg-offbase overflow-hidden">
<div
className="h-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
/>
</div>
<p className="mt-1.5 text-[10px] text-muted">
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}
</p>
</div>
{!isLoading && parseUiState === 'failed' ? (
<div className="mt-3 flex justify-start">
<button
type="button"
onClick={requestForceReparse}
className="inline-flex items-center rounded-md border border-offbase bg-base px-3 py-1.5 text-xs font-medium text-foreground hover:text-accent transition-colors"
>
Retry Parse
</button>
</div>
) : null}
</div>
</div>
) : (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 p-4 shadow-sm transition-all duration-300 ease-out overflow-hidden">
<div className="h-0.5 -mx-4 -mt-4 mb-3 bg-[linear-gradient(90deg,var(--accent),transparent_75%)]" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{compactLabel}</p>
<p className="mt-1 text-xs text-muted">{compactSubLabel}</p>
</div>
<span className="inline-flex items-center justify-center rounded-md border border-offbase bg-base p-1.5">
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
<span className="h-1.5 rounded-full bg-accent/30 animate-pulse" />
<span className="h-1.5 rounded-full bg-accent/20 animate-pulse [animation-delay:120ms]" />
<span className="h-1.5 rounded-full bg-accent/15 animate-pulse [animation-delay:220ms]" />
</div>
</div>
)}
</div>
</div>
);
};
return (
<>
<Header
left={
<Link
href="/app"
onClick={() => 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"
>
@ -199,15 +413,21 @@ export default function PDFViewerPage() {
</div>
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
<div className="relative overflow-hidden" style={{ height: containerHeight }}>
{isParseReady ? (
<div className={isPdfViewerReady ? 'h-full' : 'h-full opacity-0 pointer-events-none'}>
<PDFViewer
zoomLevel={zoomLevel}
onDocumentReady={() => setIsPdfViewerReady(true)}
pdfState={pdfState}
/>
</div>
) : (
<PDFViewer zoomLevel={zoomLevel} pdfState={pdfState} />
)}
) : null}
{isLoading || !isParseReady || !isPdfViewerReady ? (
<div className="absolute inset-0 z-10" data-testid="pdf-status-loader">
{renderPdfStatusLoader()}
</div>
) : null}
</div>
{canExportAudiobook && (
<AudiobookExportModal
@ -226,12 +446,41 @@ export default function PDFViewerPage() {
<RateLimitBanner />
</div>
</div>
) : (
) : isParseReady ? (
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
)}
) : null}
<DocumentSettings
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
pdf={{
parseStatus,
parsedOverlayEnabled,
skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [],
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 ?? {}),
skipBlockKinds: Array.from(current),
},
});
},
onForceReparse: requestForceReparse,
}}
/>
<ConfirmDialog
isOpen={showForceReparseConfirm}
onClose={() => setShowForceReparseConfirm(false)}
onConfirm={confirmForceReparse}
title={FORCE_REPARSE_CONFIRM_TITLE}
message={FORCE_REPARSE_CONFIRM_MESSAGE}
confirmText={FORCE_REPARSE_CONFIRM_TEXT}
cancelText="Cancel"
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}

View file

@ -18,26 +18,41 @@ import {
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { getDocumentMetadata } from '@/lib/client/api/documents';
import {
forceReparsePdfDocument,
getDocumentMetadata,
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';
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 { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '@/lib/client/pdf-tts-planning';
import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan';
import {
DEFAULT_DOCUMENT_SETTINGS,
type DocumentSettings,
} from '@/types/document-settings';
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import type { ParsedPdfDocument, ParsedPdfPage, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import type {
TTSSentenceAlignment,
TTSAudiobookFormat,
TTSAudiobookChapter,
} from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
import { clampSegmentPreloadDepth } from '@/types/config';
/**
@ -52,12 +67,29 @@ export interface PdfDocumentState {
currDocPage: number;
currDocText: string | undefined;
pdfDocument: PDFDocumentProxy | undefined;
setCurrentDocument: (id: string) => Promise<void>;
parsedDocument: ParsedPdfDocument | null;
parseStatus: PdfParseStatus | null;
parseProgress: PdfParseProgress | null;
documentSettings: DocumentSettings;
updateDocumentSettings: (settings: DocumentSettings) => Promise<void>;
parsedOverlayEnabled: boolean;
setParsedOverlayEnabled: (enabled: boolean) => void;
forceReparseParsedPdf: () => Promise<void>;
setCurrentDocument: (id: string) => Promise<boolean>;
clearCurrDoc: () => void;
// PDF functionality
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
highlightPattern: (text: string, pattern: string, containerRef: RefObject<HTMLDivElement>) => void;
highlightPattern: (
text: string,
pattern: string,
containerRef: RefObject<HTMLDivElement>,
options?: {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
},
) => void;
clearHighlights: () => void;
clearWordHighlights: () => void;
highlightWordIndex: (
@ -84,9 +116,6 @@ export interface PdfDocumentState {
isAudioCombining: boolean;
}
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
const EMPTY_TEXT_MAX_RETRIES = 6;
/**
* Main PDF route hook.
*/
@ -101,14 +130,9 @@ export function usePdfDocument(): PdfDocumentState {
registerVisualPageChangeHandler,
} = useTTS();
const {
headerMargin,
footerMargin,
leftMargin,
rightMargin,
apiKey,
baseUrl,
providerRef,
smartSentenceSplitting,
segmentPreloadDepthPages,
ttsSegmentMaxBlockLength,
} = useConfig();
@ -119,18 +143,18 @@ export function usePdfDocument(): PdfDocumentState {
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
const [parsedDocument, setParsedDocument] = useState<ParsedPdfDocument | null>(null);
const [parseStatus, setParseStatus] = useState<PdfParseStatus | null>(null);
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
const [, setActiveParseOpId] = useState<string | null>(null);
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false);
const [isAudioCombining] = useState(false);
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
pdfDocument,
margins: {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin,
},
smartSentenceSplitting,
parsed: parsedDocument ?? undefined,
settings: documentSettings,
maxBlockLength: ttsSegmentMaxBlockLength,
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
}), [parsedDocument, documentSettings, ttsSegmentMaxBlockLength]);
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
@ -139,11 +163,129 @@ export function usePdfDocument(): PdfDocumentState {
const pdfDocGenerationRef = useRef(0);
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
const loadSeqRef = useRef(0);
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
const docLoadSeqRef = useRef(0);
const docLoadAbortRef = useRef<AbortController | null>(null);
const parsePollAbortRef = useRef<AbortController | null>(null);
const parseSseCloseRef = useRef<(() => void) | null>(null);
const fetchParsedDocument = useCallback(async (
documentId: string,
initialStatus: PdfParseStatus | null,
signal: AbortSignal,
initialOpId?: string | null,
): Promise<void> => {
// 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);
setParseProgress(null);
const delayMs = 1200;
const retryFailed = effectiveInitialStatus === 'failed';
let attempt = 0;
let effectiveOpId = initialOpId?.trim() || null;
while (!signal.aborted) {
if (signal.aborted) return;
const result = await getParsedPdfDocument(documentId, {
signal,
retryFailed: retryFailed && attempt === 0,
...(effectiveOpId ? { opId: effectiveOpId } : {}),
});
if (result.status === 'ready') {
setParsedDocument(result.parsed);
setParseStatus('ready');
setParseProgress(null);
setActiveParseOpId(null);
return;
}
if ('opId' in result && typeof result.opId === 'string' && result.opId.trim()) {
effectiveOpId = result.opId.trim();
setActiveParseOpId(effectiveOpId);
}
setParseStatus(result.status);
setParseProgress(result.parseProgress ?? null);
if (result.status === 'failed') {
setParsedDocument(null);
setActiveParseOpId(null);
return;
}
attempt += 1;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}, []);
const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise<void> => {
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, initialOpId?: string | null) => {
parsePollAbortRef.current?.abort();
parseSseCloseRef.current?.();
parseSseCloseRef.current = null;
setParseProgress(null);
setActiveParseOpId(initialOpId?.trim() || null);
const controller = new AbortController();
parsePollAbortRef.current = controller;
const closeSse = subscribeParsedPdfDocumentEvents(documentId, {
opId: initialOpId?.trim() || null,
}, {
onSnapshot: (snapshot) => {
if (controller.signal.aborted) return;
if (typeof snapshot.opId === 'string' && snapshot.opId.trim()) {
setActiveParseOpId(snapshot.opId.trim());
}
setParseStatus(snapshot.parseStatus);
setParseProgress(snapshot.parseProgress);
if (snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed') {
if (snapshot.parseStatus === 'failed') {
setParsedDocument(null);
setActiveParseOpId(null);
} else {
void fetchParsedDocument(
documentId,
'ready',
controller.signal,
typeof snapshot.opId === 'string' ? snapshot.opId : (initialOpId ?? null),
);
}
closeSse();
parseSseCloseRef.current = null;
if (parsePollAbortRef.current === controller) {
parsePollAbortRef.current = null;
}
return;
}
},
onError: () => {
// EventSource reconnects automatically. Keep stream open, but log so
// production debugging can correlate UI stalls with SSE churn.
if (controller.signal.aborted) return;
console.warn('[pdf] parsed/events stream error; waiting for auto-reconnect', {
documentId,
});
},
});
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, setActiveParseOpId]);
useEffect(() => {
pdfDocumentRef.current = pdfDocument;
@ -167,7 +309,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<void>}
*/
@ -179,20 +321,18 @@ 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 pageFromParsed = (pageNum: number): ParsedPdfPage | undefined =>
parsedDocument?.pages.find((page) => page.pageNumber === pageNum);
const margins = {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
if (parseStatus !== 'ready' || !parsedDocument) {
setCurrDocText(undefined);
setTTSText('', { location: currDocPageNumber });
return;
}
const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => {
const page = pageFromParsed(pageNum);
return buildPdfPageSourceUnits(page, pageNum, documentSettings.pdf?.skipBlockKinds ?? []);
};
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
@ -209,7 +349,10 @@ export function usePdfDocument(): PdfDocumentState {
return cached;
}
const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins);
const parsedPage = pageFromParsed(pageNumber);
const extracted = parsedPage
? buildPageTextFromBlocks(parsedPage, documentSettings.pdf?.skipBlockKinds ?? [])
: '';
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
throw new DOMException('Stale PDF extraction', 'AbortError');
@ -237,14 +380,15 @@ export function usePdfDocument(): PdfDocumentState {
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)),
]);
const nextText = upcomingTexts[0];
const additionalUpcoming = upcomingPageNumbers
.slice(1)
.map((pageNum, idx) => ({
location: pageNum,
text: upcomingTexts[idx + 1] || '',
}))
.filter((item) => item.text.trim().length > 0);
const {
nextText,
nextSourceUnits,
additionalUpcoming,
} = buildPdfPrefetchPayload(
upcomingPageNumbers,
upcomingTexts,
sourceUnitsFromParsedPage,
);
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
@ -253,40 +397,17 @@ 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);
setTTSText(text, {
location: currDocPageNumber,
previousText: prevText,
nextLocation: nextPageNumber,
nextText: nextText,
nextSourceUnits,
upcomingLocations: additionalUpcoming,
...(sourceUnits.length > 0 ? { sourceUnits } : {}),
});
}
} catch (error) {
@ -300,11 +421,10 @@ export function usePdfDocument(): PdfDocumentState {
currDocPages,
setTTSText,
currDocText,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
segmentPreloadDepthPages,
parsedDocument,
parseStatus,
documentSettings,
]);
/**
@ -324,7 +444,7 @@ export function usePdfDocument(): PdfDocumentState {
* @param {string} id - The unique identifier of the document to set
* @returns {Promise<void>}
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
// --- race-condition guard ---
const seq = ++docLoadSeqRef.current;
docLoadAbortRef.current?.abort();
@ -337,10 +457,10 @@ 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;
parseSseCloseRef.current?.();
parseSseCloseRef.current = null;
pageTextCacheRef.current.clear();
setPdfDocument(undefined);
setCurrDocPages(undefined);
@ -348,35 +468,85 @@ export function usePdfDocument(): PdfDocumentState {
setCurrDocId(id);
setCurrDocName(undefined);
setCurrDocData(undefined);
setParsedDocument(null);
setParseStatus(null);
setParseProgress(null);
setActiveParseOpId(null);
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') {
const initialParseStatus = (meta.parseStatus ?? null) as PdfParseStatus | null;
setParseStatus(initialParseStatus);
setParseProgress(null);
setActiveParseOpId(null);
startParsedPolling(id, null);
void fetchDocumentSettings(id, controller.signal);
}
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;
}
}
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
return false;
}, [
setCurrDocId,
setCurrDocName,
setCurrDocData,
setCurrDocPages,
setCurrDocText,
setPdfDocument,
fetchDocumentSettings,
startParsedPolling,
]);
const updateDocumentSettings = useCallback(async (settings: DocumentSettings): Promise<void> => {
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<void> => {
if (!currDocId) return;
try {
const forced = await forceReparsePdfDocument(currDocId);
setParsedDocument(null);
setParseStatus(forced.status);
setParseProgress(null);
setActiveParseOpId(forced.opId ?? null);
startParsedPolling(currDocId, forced.opId ?? null);
} catch (error) {
console.error('Failed to force PDF reparse:', error);
}
}, [currDocId, startParsedPolling]);
/**
* Clears the current document state
@ -390,16 +560,21 @@ export function usePdfDocument(): PdfDocumentState {
docLoadSeqRef.current += 1;
docLoadAbortRef.current?.abort();
docLoadAbortRef.current = null;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}
emptyRetryRef.current = null;
parsePollAbortRef.current?.abort();
parsePollAbortRef.current = null;
parseSseCloseRef.current?.();
parseSseCloseRef.current = null;
setCurrDocId(undefined);
setCurrDocName(undefined);
setCurrDocData(undefined);
setCurrDocText(undefined);
setCurrDocPages(undefined);
setPdfDocument(undefined);
setParsedDocument(null);
setParseStatus(null);
setParseProgress(null);
setActiveParseOpId(null);
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
pageTextCacheRef.current.clear();
stop();
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
@ -499,6 +674,14 @@ export function usePdfDocument(): PdfDocumentState {
currDocPages,
currDocPage,
currDocText,
parsedDocument,
parseStatus,
parseProgress,
documentSettings,
updateDocumentSettings,
parsedOverlayEnabled,
setParsedOverlayEnabled,
forceReparseParsedPdf,
clearCurrDoc,
highlightPattern,
clearHighlights,
@ -518,6 +701,14 @@ export function usePdfDocument(): PdfDocumentState {
currDocPages,
currDocPage,
currDocText,
parsedDocument,
parseStatus,
parseProgress,
documentSettings,
updateDocumentSettings,
parsedOverlayEnabled,
setParsedOverlayEnabled,
forceReparseParsedPdf,
clearCurrDoc,
pdfDocument,
createFullAudioBook,

View file

@ -4,6 +4,8 @@ import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
export async function DELETE() {
if (!isAuthEnabled() || !auth) {
@ -28,7 +30,13 @@ export async function DELETE() {
try {
await deleteUserStorageData(session.user.id, testNamespace);
} catch (error) {
console.error('[account-delete] Failed to clean up namespaced user storage before deletion:', error);
serverLogger.warn({
event: 'account.delete.storage_cleanup_failed',
degraded: true,
step: 'namespaced_storage_cleanup',
userIdHash: hashForLog(session.user.id),
error: errorToLog(error),
}, 'Failed to clean up namespaced user storage before deletion');
}
}
@ -40,10 +48,13 @@ export async function DELETE() {
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete account:', error);
return NextResponse.json(
{ error: 'Failed to delete account' },
{ status: 500 }
);
serverLogger.error({
event: 'account.delete.failed',
error: errorToLog(error),
}, 'Failed to delete account');
return errorResponse(error, {
apiErrorMessage: 'Failed to delete account',
normalize: { code: 'ACCOUNT_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
AdminProviderError,
deleteAdminProvider,
@ -39,10 +41,25 @@ export async function PUT(
return NextResponse.json({ provider: toMasked(updated) });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
return errorResponse(error, {
apiErrorMessage: error.message,
normalize: {
code: 'ADMIN_PROVIDERS_UPDATE_REQUEST_FAILED',
errorClass: error.status >= 500 ? 'db' : 'validation',
httpStatus: error.status,
retryable: error.status >= 500,
},
});
}
console.error('[admin/providers/:id] update failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
serverLogger.error({
event: 'admin.providers.update.failed',
providerId: id,
error: errorToLog(error),
}, 'Admin provider update failed');
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_UPDATE_FAILED', errorClass: 'db' },
});
}
}
@ -59,9 +76,24 @@ export async function DELETE(
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
return errorResponse(error, {
apiErrorMessage: error.message,
normalize: {
code: 'ADMIN_PROVIDERS_DELETE_REQUEST_FAILED',
errorClass: error.status >= 500 ? 'db' : 'validation',
httpStatus: error.status,
retryable: error.status >= 500,
},
});
}
console.error('[admin/providers/:id] delete failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
serverLogger.error({
event: 'admin.providers.delete.failed',
providerId: id,
error: errorToLog(error),
}, 'Admin provider delete failed');
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
AdminProviderError,
createAdminProvider,
@ -44,9 +46,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ provider: toMasked(record) }, { status: 201 });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
return errorResponse(error, {
apiErrorMessage: error.message,
normalize: {
code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED',
errorClass: error.status >= 500 ? 'db' : 'validation',
httpStatus: error.status,
retryable: error.status >= 500,
},
});
}
console.error('[admin/providers] create failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
serverLogger.error({
event: 'admin.providers.create.failed',
error: errorToLog(error),
}, 'Admin provider create failed');
return errorResponse(error, {
apiErrorMessage: 'Internal error',
normalize: { code: 'ADMIN_PROVIDERS_CREATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -11,6 +11,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
deleteAudiobookObject,
getAudiobookObjectBuffer,
@ -43,6 +44,7 @@ import {
coerceAudiobookGenerationSettings,
type SharedProviderPolicyEntry,
} from '@/lib/server/audiobooks/settings';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
@ -185,7 +187,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
}
ffmpeg.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
serverLogger.warn({
event: 'audiobook.chapter.ffmpeg.stderr',
degraded: true,
step: 'ffmpeg',
stderr: String(data),
}, 'ffmpeg stderr');
});
ffmpeg.on('close', (code) => {
@ -325,8 +332,19 @@ export async function POST(request: NextRequest) {
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
});
if (!existingResult.settings) {
console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId });
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
serverLogger.error({
event: 'audiobook.chapter.meta_settings.invalid',
bookId,
storageUserId,
error: {
name: 'AudiobookMetaSettingsInvalid',
message: 'Invalid audiobook.meta.json settings payload',
},
}, 'Invalid audiobook.meta.json settings payload');
return errorResponse(new Error('Invalid audiobook metadata settings payload'), {
apiErrorMessage: 'Invalid audiobook metadata settings',
normalize: { code: 'AUDIOBOOK_CHAPTER_META_SETTINGS_INVALID', errorClass: 'validation', httpStatus: 500 },
});
}
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
existingSettingsNeedsMigration = existingResult.migrated;
@ -399,11 +417,14 @@ export async function POST(request: NextRequest) {
testNamespace,
);
} catch (error) {
console.warn('Failed to persist migrated audiobook metadata settings', {
serverLogger.warn({
event: 'audiobook.chapter.meta_settings.persist_migration_failed',
degraded: true,
step: 'persist_migrated_settings',
bookId,
storageUserId,
error: error instanceof Error ? error.message : String(error),
});
error: errorToLog(error),
}, 'Failed to persist migrated audiobook metadata settings');
}
}
@ -486,7 +507,10 @@ export async function POST(request: NextRequest) {
}
const provider = credResolved.provider;
if (!isBuiltInTtsProviderId(provider)) {
return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 });
return errorResponse(new Error(`Unsupported TTS provider type: ${provider}`), {
apiErrorMessage: `Unsupported TTS provider type: ${provider}`,
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
});
}
const openApiKey = credResolved.apiKey || 'none';
const openApiBaseUrl = credResolved.baseUrl;
@ -608,7 +632,12 @@ export async function POST(request: NextRequest) {
request.signal,
);
} catch (copyError) {
console.warn('Chapter remux failed; falling back to mp3 re-encode:', copyError);
serverLogger.warn({
event: 'audiobook.chapter.remux.failed',
degraded: true,
fallbackPath: 'mp3_reencode',
error: errorToLog(copyError),
}, 'Chapter remux failed; falling back to mp3 re-encode');
await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
@ -728,8 +757,14 @@ export async function POST(request: NextRequest) {
return response;
}
console.error('Error processing audio chapter:', error);
const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
serverLogger.error({
event: 'audiobook.chapter.process.failed',
error: errorToLog(error),
}, 'Failed to process audio chapter');
const response = errorResponse(error, {
apiErrorMessage: 'Failed to process audio chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', errorClass: 'upstream' },
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} finally {
@ -823,8 +858,14 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
console.error('Error downloading chapter:', error);
return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 });
serverLogger.error({
event: 'audiobook.chapter.download.failed',
error: errorToLog(error),
}, 'Failed to download chapter');
return errorResponse(error, {
apiErrorMessage: 'Failed to download chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DOWNLOAD_FAILED', errorClass: 'storage' },
});
}
}
@ -891,7 +932,13 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting chapter:', error);
return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 });
serverLogger.error({
event: 'audiobook.chapter.delete.failed',
error: errorToLog(error),
}, 'Failed to delete chapter');
return errorResponse(error, {
apiErrorMessage: 'Failed to delete chapter',
normalize: { code: 'AUDIOBOOK_CHAPTER_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -7,6 +7,8 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
audiobookPrefix,
deleteAudiobookObject,
@ -115,7 +117,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
}
ffmpeg.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
serverLogger.warn({
event: 'audiobook.ffmpeg.stderr',
degraded: true,
step: 'ffmpeg',
stderr: String(data),
}, 'ffmpeg stderr');
});
ffmpeg.on('close', (code) => {
@ -282,7 +289,12 @@ export async function GET(request: NextRequest) {
request.signal,
);
} catch (copyError) {
console.warn('MP3 concat copy failed; falling back to re-encode:', copyError);
serverLogger.warn({
event: 'audiobook.concat_copy.mp3.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'MP3 concat copy failed; falling back to re-encode');
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
request.signal,
@ -311,7 +323,12 @@ export async function GET(request: NextRequest) {
request.signal,
);
} catch (copyError) {
console.warn('M4B concat copy failed; falling back to re-encode:', copyError);
serverLogger.warn({
event: 'audiobook.concat_copy.m4b.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'M4B concat copy failed; falling back to re-encode');
await runFFmpeg(
[
'-f',
@ -360,8 +377,14 @@ export async function GET(request: NextRequest) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
console.error('Error creating full audiobook:', error);
return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
serverLogger.error({
event: 'audiobook.create.failed',
error: errorToLog(error),
}, 'Failed to create full audiobook');
return errorResponse(error, {
apiErrorMessage: 'Failed to create full audiobook file',
normalize: { code: 'AUDIOBOOK_CREATE_FAILED', errorClass: 'upstream' },
});
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
@ -408,7 +431,13 @@ export async function DELETE(request: NextRequest) {
const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
return NextResponse.json({ success: true, existed: deleted > 0 });
} catch (error) {
console.error('Error resetting audiobook:', error);
return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 });
serverLogger.error({
event: 'audiobook.reset.failed',
error: errorToLog(error),
}, 'Failed to reset audiobook');
return errorResponse(error, {
apiErrorMessage: 'Failed to reset audiobook',
normalize: { code: 'AUDIOBOOK_RESET_FAILED', errorClass: 'db' },
});
}
}

Some files were not shown because too many files have changed in this diff Show more