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:
commit
218e248cc2
234 changed files with 27768 additions and 2131 deletions
60
.env.example
60
.env.example
|
|
@ -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
|
||||
|
|
|
|||
126
.github/workflows/docker-publish.yml
vendored
126
.github/workflows/docker-publish.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
11
.github/workflows/playwright.yml
vendored
11
.github/workflows/playwright.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -33,7 +33,9 @@ yarn-error.log*
|
|||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
.env.prod
|
||||
.dockerignore
|
||||
*.creds
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
43
Dockerfile
43
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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
21
compute/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
120
compute/core/src/api-contracts/index.ts
Normal file
120
compute/core/src/api-contracts/index.ts
Normal 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>;
|
||||
}
|
||||
28
compute/core/src/config/cpu-budget.ts
Normal file
28
compute/core/src/config/cpu-budget.ts
Normal 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));
|
||||
}
|
||||
119
compute/core/src/config/timeout.ts
Normal file
119
compute/core/src/config/timeout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
139
compute/core/src/control-plane/in-memory.ts
Normal file
139
compute/core/src/control-plane/in-memory.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
5
compute/core/src/control-plane/index.ts
Normal file
5
compute/core/src/control-plane/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './types';
|
||||
export * from './state-machine';
|
||||
export * from './orchestrator';
|
||||
export * from './in-memory';
|
||||
export * from './sse';
|
||||
288
compute/core/src/control-plane/orchestrator.ts
Normal file
288
compute/core/src/control-plane/orchestrator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
compute/core/src/control-plane/sse.ts
Normal file
49
compute/core/src/control-plane/sse.ts
Normal 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;
|
||||
}
|
||||
|
||||
65
compute/core/src/control-plane/state-machine.ts
Normal file
65
compute/core/src/control-plane/state-machine.ts
Normal 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;
|
||||
}
|
||||
63
compute/core/src/control-plane/types.ts
Normal file
63
compute/core/src/control-plane/types.ts
Normal 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
24
compute/core/src/index.ts
Normal 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';
|
||||
40
compute/core/src/local-runtime.ts
Normal file
40
compute/core/src/local-runtime.ts
Normal 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 };
|
||||
}
|
||||
3
compute/core/src/pdf/assets/LICENSE.txt
Normal file
3
compute/core/src/pdf/assets/LICENSE.txt
Normal 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
|
||||
31
compute/core/src/pdf/assets/manifest.json
Normal file
31
compute/core/src/pdf/assets/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
153
compute/core/src/pdf/merge.ts
Normal file
153
compute/core/src/pdf/merge.ts
Normal 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;
|
||||
}
|
||||
147
compute/core/src/pdf/model.ts
Normal file
147
compute/core/src/pdf/model.ts
Normal 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;
|
||||
}
|
||||
35
compute/core/src/pdf/normalize-text.ts
Normal file
35
compute/core/src/pdf/normalize-text.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
150
compute/core/src/pdf/parse.ts
Normal file
150
compute/core/src/pdf/parse.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
166
compute/core/src/pdf/render.ts
Normal file
166
compute/core/src/pdf/render.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
339
compute/core/src/pdf/runLayoutModel.ts
Normal file
339
compute/core/src/pdf/runLayoutModel.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
144
compute/core/src/pdf/stitch.ts
Normal file
144
compute/core/src/pdf/stitch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
compute/core/src/pdf/types.ts
Normal file
15
compute/core/src/pdf/types.ts
Normal 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;
|
||||
}
|
||||
21
compute/core/src/platform/docstore.ts
Normal file
21
compute/core/src/platform/docstore.ts
Normal 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();
|
||||
36
compute/core/src/platform/ffmpeg.ts
Normal file
36
compute/core/src/platform/ffmpeg.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
17
compute/core/src/types/index.ts
Normal file
17
compute/core/src/types/index.ts
Normal 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';
|
||||
63
compute/core/src/types/parsed-pdf.ts
Normal file
63
compute/core/src/types/parsed-pdf.ts
Normal 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;
|
||||
}
|
||||
16
compute/core/src/types/tts.ts
Normal file
16
compute/core/src/types/tts.ts
Normal 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[];
|
||||
}
|
||||
1014
compute/core/src/whisper/align.ts
Normal file
1014
compute/core/src/whisper/align.ts
Normal file
File diff suppressed because it is too large
Load diff
54
compute/core/src/whisper/alignment-map.ts
Normal file
54
compute/core/src/whisper/alignment-map.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
compute/core/src/whisper/assets/LICENSE.txt
Normal file
21
compute/core/src/whisper/assets/LICENSE.txt
Normal 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.
|
||||
76
compute/core/src/whisper/assets/manifest.json
Normal file
76
compute/core/src/whisper/assets/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
compute/core/src/whisper/assets/mel_filters.npz
Normal file
BIN
compute/core/src/whisper/assets/mel_filters.npz
Normal file
Binary file not shown.
249
compute/core/src/whisper/model.ts
Normal file
249
compute/core/src/whisper/model.ts
Normal 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();
|
||||
}
|
||||
21
compute/core/src/whisper/spectral.ts
Normal file
21
compute/core/src/whisper/spectral.ts
Normal 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;
|
||||
}
|
||||
449
compute/core/src/whisper/token-timestamps.ts
Normal file
449
compute/core/src/whisper/token-timestamps.ts
Normal 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);
|
||||
}
|
||||
7
compute/core/tsconfig.json
Normal file
7
compute/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
58
compute/worker/.env.example
Normal file
58
compute/worker/.env.example
Normal 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
29
compute/worker/Dockerfile
Normal 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"]
|
||||
47
compute/worker/docker-compose.yml
Normal file
47
compute/worker/docker-compose.yml
Normal 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:
|
||||
22
compute/worker/package.json
Normal file
22
compute/worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
315
compute/worker/src/control-plane/jetstream.ts
Normal file
315
compute/worker/src/control-plane/jetstream.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
compute/worker/src/control-plane/json-codec.ts
Normal file
17
compute/worker/src/control-plane/json-codec.ts
Normal 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
1322
compute/worker/src/server.ts
Normal file
File diff suppressed because it is too large
Load diff
8
compute/worker/tsconfig.json
Normal file
8
compute/worker/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
docs-site/docs/deploy/compute-worker.md
Normal file
221
docs-site/docs/deploy/compute-worker.md
Normal 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`
|
||||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
drizzle/postgres/0005_pdf_layout_and_compute.sql
Normal file
14
drizzle/postgres/0005_pdf_layout_and_compute.sql
Normal 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");
|
||||
2
drizzle/postgres/0006_preview-defaults-cleanup.sql
Normal file
2
drizzle/postgres/0006_preview-defaults-cleanup.sql
Normal 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;
|
||||
2
drizzle/postgres/0007_pdf_parse_progress.sql
Normal file
2
drizzle/postgres/0007_pdf_parse_progress.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "documents" ADD COLUMN "parse_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "documents" DROP COLUMN "parse_status";
|
||||
1833
drizzle/postgres/meta/0005_snapshot.json
Normal file
1833
drizzle/postgres/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1831
drizzle/postgres/meta/0006_snapshot.json
Normal file
1831
drizzle/postgres/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1831
drizzle/postgres/meta/0007_snapshot.json
Normal file
1831
drizzle/postgres/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
14
drizzle/sqlite/0005_pdf_layout_and_compute.sql
Normal file
14
drizzle/sqlite/0005_pdf_layout_and_compute.sql
Normal 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;
|
||||
27
drizzle/sqlite/0006_preview-defaults-cleanup.sql
Normal file
27
drizzle/sqlite/0006_preview-defaults-cleanup.sql
Normal 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`);
|
||||
2
drizzle/sqlite/0007_pdf_parse_progress.sql
Normal file
2
drizzle/sqlite/0007_pdf_parse_progress.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `documents` ADD `parse_state` text;--> statement-breakpoint
|
||||
ALTER TABLE `documents` DROP COLUMN `parse_status`;
|
||||
1695
drizzle/sqlite/meta/0005_snapshot.json
Normal file
1695
drizzle/sqlite/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1693
drizzle/sqlite/meta/0006_snapshot.json
Normal file
1693
drizzle/sqlite/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1693
drizzle/sqlite/meta/0007_snapshot.json
Normal file
1693
drizzle/sqlite/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
export default {};
|
||||
const emptyModule = {};
|
||||
|
||||
export default emptyModule;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
||||
"dev:raw": "next dev --turbopack -p 3003",
|
||||
"build": "next build",
|
||||
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
|
||||
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
|
||||
"start:raw": "next start -p 3003",
|
||||
"lint": "next lint",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1174
pnpm-lock.yaml
1174
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
50
scripts/check-next-server-bundle.mjs
Normal file
50
scripts/check-next-server-bundle.mjs
Normal 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');
|
||||
54
scripts/check-route-error-responses.mjs
Normal file
54
scripts/check-route-error-responses.mjs
Normal 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.');
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue