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)
|
# Local / OpenAI TTS API Configuration (default)
|
||||||
# Suggest using https://github.com/remsky/Kokoro-FastAPI
|
# Suggest using https://github.com/remsky/Kokoro-FastAPI
|
||||||
#
|
#
|
||||||
|
|
@ -77,8 +83,39 @@ RUN_FS_MIGRATIONS=
|
||||||
IMPORT_LIBRARY_DIR=
|
IMPORT_LIBRARY_DIR=
|
||||||
IMPORT_LIBRARY_DIRS=
|
IMPORT_LIBRARY_DIRS=
|
||||||
|
|
||||||
# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation
|
# Compute
|
||||||
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
|
# 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
|
# (Optional) Override ffmpeg binary path used for audiobook processing
|
||||||
FFMPEG_BIN=
|
FFMPEG_BIN=
|
||||||
|
|
@ -86,13 +123,12 @@ FFMPEG_BIN=
|
||||||
# (Optional) Client feature flags — seeded into the admin-managed runtime
|
# (Optional) Client feature flags — seeded into the admin-managed runtime
|
||||||
# config on first boot, then ignored. Edit values from Settings → Admin →
|
# config on first boot, then ignored. Edit values from Settings → Admin →
|
||||||
# Site features instead of redeploying. SSR-injected so they take effect
|
# Site features instead of redeploying. SSR-injected so they take effect
|
||||||
# without rebuilding (unlike the old NEXT_PUBLIC_* build-time pattern).
|
# without rebuilding (unlike the old build-time public-env pattern).
|
||||||
# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true
|
# RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true
|
||||||
# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
|
# RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
|
||||||
# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true
|
# RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true
|
||||||
# NEXT_PUBLIC_ENABLE_USER_SIGNUPS=true
|
# RUNTIME_SEED_ENABLE_USER_SIGNUPS=true
|
||||||
# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true
|
# RUNTIME_SEED_RESTRICT_USER_API_KEYS=true
|
||||||
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
|
# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai
|
||||||
# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
|
# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
|
||||||
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
|
# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true
|
||||||
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true
|
|
||||||
|
|
|
||||||
126
.github/workflows/docker-publish.yml
vendored
126
.github/workflows/docker-publish.yml
vendored
|
|
@ -22,30 +22,17 @@ jobs:
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
image_name: ${{ steps.image-name.outputs.image_name }}
|
web_image_name: ${{ steps.image-name.outputs.web_image_name }}
|
||||||
legacy_image_name: ${{ steps.image-name.outputs.legacy_image_name }}
|
web_legacy_image_name: ${{ steps.image-name.outputs.web_legacy_image_name }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
compute_worker_image_name: ${{ steps.image-name.outputs.compute_worker_image_name }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Compute Docker image names
|
- name: Compute Docker image names
|
||||||
id: image-name
|
id: image-name
|
||||||
run: |
|
run: |
|
||||||
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||||
echo "image_name=${owner}/openreader" >> "$GITHUB_OUTPUT"
|
echo "web_image_name=${owner}/openreader" >> "$GITHUB_OUTPUT"
|
||||||
echo "legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT"
|
echo "web_legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "compute_worker_image_name=${owner}/openreader-compute-worker" >> "$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 }}
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
|
|
@ -58,12 +45,30 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: amd64
|
- image_target: web
|
||||||
|
arch: amd64
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runner: ubuntu-24.04
|
runner: ubuntu-24.04
|
||||||
- arch: arm64
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
- image_target: web
|
||||||
|
arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -82,46 +87,63 @@ jobs:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ matrix.context }}
|
||||||
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ needs.prepare.outputs.labels }}
|
cache-from: type=gha,scope=${{ matrix.image_target }}-${{ matrix.arch }}
|
||||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.image_target }}-${{ matrix.arch }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
|
||||||
provenance: false
|
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
|
- name: Export digest
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/digests
|
mkdir -p /tmp/digests/${{ matrix.image_target }}
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${{ matrix.image_target }}/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.arch }}
|
name: digests-${{ matrix.image_target }}-${{ matrix.arch }}
|
||||||
path: /tmp/digests
|
path: /tmp/digests/${{ matrix.image_target }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
needs: [prepare, build]
|
needs: [prepare, build]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image_target: web
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- image_target: compute-worker
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests/${{ matrix.image_target }}
|
||||||
pattern: digests-*
|
pattern: digests-${{ matrix.image_target }}-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|
@ -134,20 +156,46 @@ jobs:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Create manifest list and push
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests/${{ matrix.image_target }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
$(echo "$TAGS" | xargs -I {} echo -t {}) \
|
$(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:
|
env:
|
||||||
TAGS: ${{ needs.prepare.outputs.tags }}
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
|
||||||
- name: Output build information
|
- name: Output build information
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Docker images built and pushed successfully!"
|
echo "✅ Docker image built and pushed successfully!"
|
||||||
|
echo "🐋 Target: ${{ matrix.image_target }}"
|
||||||
echo "🐋 Images:"
|
echo "🐋 Images:"
|
||||||
echo '${{ needs.prepare.outputs.tags }}' | sed 's/^/ - /'
|
echo '${{ steps.meta.outputs.tags }}' | sed 's/^/ - /'
|
||||||
echo "📝 Event: ${{ github.event_name }}"
|
echo "📝 Event: ${{ github.event_name }}"
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
echo "📝 Triggered by manual workflow dispatch on branch: ${{ github.ref_name }}"
|
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
|
sudo install -m 0755 ./weed /usr/local/bin/weed
|
||||||
rm -f ./weed
|
rm -f ./weed
|
||||||
weed version
|
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
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build app and enforce bundle guard
|
||||||
|
run: |
|
||||||
|
pnpm build
|
||||||
|
pnpm build:bundle-guard
|
||||||
- name: Verify ffprobe
|
- name: Verify ffprobe
|
||||||
run: ffprobe -version
|
run: ffprobe -version
|
||||||
- name: Install Playwright Browsers
|
- 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 files (can opt-in for committing if needed)
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
*.creds
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
|
||||||
43
Dockerfile
43
Dockerfile
|
|
@ -1,24 +1,14 @@
|
||||||
# Stage 1: build whisper.cpp (no model download – the app handles that)
|
# Stage 1: extract seaweedfs weed binary (for optional embedded weed mini)
|
||||||
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)
|
|
||||||
# Pin to 4.18 because CI observed upload regressions on 4.19.
|
# Pin to 4.18 because CI observed upload regressions on 4.19.
|
||||||
FROM chrislusf/seaweedfs:4.18 AS seaweedfs-builder
|
FROM chrislusf/seaweedfs:4.18 AS seaweedfs-builder
|
||||||
RUN cp "$(command -v weed)" /tmp/weed && \
|
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/master/LICENSE" || \
|
||||||
wget -qO /tmp/SeaweedFS-LICENSE.txt "https://raw.githubusercontent.com/seaweedfs/seaweedfs/main/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
|
# Stage 2: build the Next.js app
|
||||||
FROM node:lts-alpine AS app-builder
|
FROM node:lts-alpine AS app-builder
|
||||||
|
|
@ -29,8 +19,10 @@ RUN npm install -g pnpm@11.1.2
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy workspace manifests needed for dependency installation
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
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
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
@ -59,29 +51,30 @@ FROM node:lts-alpine AS runner
|
||||||
# ffmpeg is provided by ffmpeg-static from node_modules.
|
# ffmpeg is provided by ffmpeg-static from node_modules.
|
||||||
RUN apk add --no-cache ca-certificates libreoffice-writer
|
RUN apk add --no-cache ca-certificates libreoffice-writer
|
||||||
|
|
||||||
# Install pnpm globally for running the app
|
# Install pnpm for runtime process commands.
|
||||||
RUN npm install -g pnpm@11.1.2
|
RUN npm install -g pnpm@10.33.4
|
||||||
|
|
||||||
# App runtime directory
|
# App runtime directory
|
||||||
WORKDIR /app
|
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 ./
|
COPY --from=app-builder /app ./
|
||||||
# Include third-party license report and copied license texts at a stable path in the image.
|
# 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
|
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
||||||
# Include SeaweedFS license text for the copied weed binary.
|
# Include SeaweedFS license text for the copied weed binary.
|
||||||
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
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 seaweedfs weed binary for optional embedded local S3.
|
||||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||||
RUN chmod +x /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
|
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||||
ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli
|
COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||||
ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
|
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 3003
|
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
|
## ✨ Highlights
|
||||||
|
|
||||||
- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra).
|
- 🧱 **Layout-aware PDF parsing** with PP-DocLayoutV3 (ONNX) — structured block detection, cross-page stitching, and geometry-based highlighting for precise read-along sync.
|
||||||
- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration.
|
- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed).
|
||||||
- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps.
|
- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback.
|
||||||
- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders.
|
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
|
||||||
- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends.
|
|
||||||
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
|
- 🎧 **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
|
## 🚀 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) |
|
| 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 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) |
|
| Develop locally | [Local Development](https://docs.openreader.richardr.dev/deploy/local-development) |
|
||||||
| Configure auth | [Auth](https://docs.openreader.richardr.dev/configure/auth) |
|
| Configure auth | [Auth](https://docs.openreader.richardr.dev/configure/auth) |
|
||||||
| Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/configure/database) |
|
| 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/)
|
- [SQLite](https://www.sqlite.org/)
|
||||||
- [PostgreSQL](https://www.postgresql.org/)
|
- [PostgreSQL](https://www.postgresql.org/)
|
||||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
|
- [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)
|
- [ffmpeg](https://ffmpeg.org)
|
||||||
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
||||||
- [react-reader](https://github.com/happyr/react-reader)
|
- [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:
|
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.
|
- **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
|
## 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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
|
||||||
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
||||||
| `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). |
|
| `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:
|
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.
|
- **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.
|
- **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
|
## 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.
|
1. Deploy this version with your existing env values in place.
|
||||||
2. Boot the app once. Open Settings → Admin and verify:
|
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).
|
- A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
|
||||||
3. Remove the env vars from your `.env`.
|
3. Remove the env vars from your `.env`.
|
||||||
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
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:
|
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.
|
- **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.
|
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.
|
**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
|
:::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
|
## 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>
|
||||||
|
|
||||||
|
<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>
|
<details>
|
||||||
<summary><strong>LibreOffice (optional, for DOCX conversion)</strong></summary>
|
<summary><strong>LibreOffice (optional, for DOCX conversion)</strong></summary>
|
||||||
|
|
||||||
|
|
@ -114,50 +149,51 @@ sudo apt install -y libreoffice
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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">
|
Word-by-word highlighting and PDF layout parsing are worker-backed in current releases.
|
||||||
<TabItem value="macos" label="macOS" default>
|
|
||||||
|
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
|
```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>
|
`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
|
||||||
<TabItem value="linux" label="Linux">
|
|
||||||
|
Run the main app separately on the host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Debian/Ubuntu example
|
pnpm dev
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y git build-essential cmake
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
For app -> external worker routing, set in root `.env`:
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
Build whisper.cpp:
|
```env
|
||||||
|
COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
```bash
|
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||||
# clone and build whisper.cpp (no model download needed – OpenReader handles that)
|
|
||||||
git clone https://github.com/ggml-org/whisper.cpp.git
|
|
||||||
cd whisper.cpp
|
|
||||||
cmake -B build
|
|
||||||
cmake --build build -j --config Release
|
|
||||||
|
|
||||||
# point OpenReader to the compiled whisper-cli binary
|
|
||||||
echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are not on Debian/Ubuntu, install equivalent packages with your distro package manager:
|
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`)
|
Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
|
||||||
- Arch: use `pacman` (`base-devel cmake curl git tar xz`)
|
For external worker mode, object storage must be shared/reachable by both app and worker services.
|
||||||
|
|
||||||
:::tip
|
|
||||||
Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting.
|
|
||||||
:::
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -186,6 +222,24 @@ cp .env.example .env
|
||||||
|
|
||||||
Then edit `.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:
|
Use one of these `.env` mode templates:
|
||||||
|
|
||||||
<Tabs groupId="local-env-modes">
|
<Tabs groupId="local-env-modes">
|
||||||
|
|
@ -239,17 +293,35 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
# Optional for non-AWS providers:
|
# Optional for non-AWS providers:
|
||||||
# S3_ENDPOINT=https://your-s3-compatible-endpoint
|
# S3_ENDPOINT=https://your-s3-compatible-endpoint
|
||||||
# S3_FORCE_PATH_STYLE=true
|
# 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>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
:::note Env vars vs. admin panel
|
:::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
|
:::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
|
:::info
|
||||||
|
|
@ -271,6 +343,9 @@ Learn about migration behavior and commands in [Migrations](../configure/migrati
|
||||||
pnpm dev
|
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>
|
||||||
<TabItem value="prod" label="Build + Start">
|
<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.
|
- Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage.
|
||||||
- Audiobook routes work on Node.js serverless functions using `ffmpeg-static`.
|
- 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
|
:::warning DOCX Conversion Limitation
|
||||||
`docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime.
|
`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=...
|
AUTH_SECRET=...
|
||||||
ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
|
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)
|
# 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
|
# 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)
|
:::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).
|
`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)
|
## 2. First-run admin configuration (recommended)
|
||||||
|
|
||||||
After the first successful deploy and admin login, open **Settings → Admin** and configure:
|
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).
|
- `defaultTtsProvider=replicate` (or your preferred shared slug).
|
||||||
- `showAllProviderModels=false` if you want users locked to each provider's default model.
|
- `showAllProviderModels=false` if you want users locked to each provider's default model.
|
||||||
- `enableAudiobookExport=true`.
|
- `enableAudiobookExport=true`.
|
||||||
- `enableWordHighlight=false` unless your timestamp stack is configured.
|
|
||||||
|
|
||||||
## 3. Legacy first-boot seed (optional)
|
## 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.
|
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`
|
||||||
- `/api/audiobook/chapter`
|
- `/api/audiobook/chapter`
|
||||||
- `/api/audiobook/status`
|
- `/api/tts/segments/ensure`
|
||||||
- `/api/whisper`
|
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
`serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output.
|
`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",
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
"functions": {
|
"functions": {
|
||||||
"app/api/audiobook/route.ts": { "memory": 3009 },
|
"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.
|
1. Upload and read a PDF/EPUB document.
|
||||||
2. Confirm sync/blob fetch works across refreshes/devices.
|
2. Confirm sync/blob fetch works across refreshes/devices.
|
||||||
3. Generate at least one audiobook chapter and play/download it.
|
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.
|
`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
|
## 1. Start the Docker container
|
||||||
|
|
||||||
<Tabs groupId="docker-start-mode">
|
<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**.
|
- 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.
|
- 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.
|
- 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.
|
- Use a `/app/docstore` mount if you want data to survive container/image replacement.
|
||||||
- Startup automatically runs DB/storage migrations via the shared entrypoint.
|
- 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
|
## 3. Update Docker image
|
||||||
|
|
||||||
Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias.
|
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
|
```bash
|
||||||
docker stop openreader || true && \
|
docker stop openreader || true && \
|
||||||
|
|
|
||||||
|
|
@ -12,28 +12,19 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ 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**
|
- 🎯 **Multi-Provider TTS Support**
|
||||||
- [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): supports multi-voice combinations (for example `af_heart+af_bella`)
|
- 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
|
||||||
- [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI): lightweight, CPU-friendly self-hosted TTS
|
- 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)
|
||||||
- [**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
|
|
||||||
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
||||||
- 🔐 **Auth Optional by Design**
|
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
|
||||||
- Run no-auth for local use, or enable auth with user isolation and claim flow
|
- 🔐 **Auth Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments
|
||||||
- 🗂️ **Flexible Storage and Database Modes** with embedded defaults or external S3/Postgres
|
- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls
|
||||||
- 🚀 **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
|
|
||||||
|
|
||||||
## 🧭 Key Docs
|
## 🧭 Key Docs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,17 @@ toc_max_heading_level: 3
|
||||||
This is the single reference page for OpenReader environment variables.
|
This is the single reference page for OpenReader environment variables.
|
||||||
|
|
||||||
:::note Recommended configuration path
|
:::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
|
## Quick Reference Table
|
||||||
|
|
||||||
| Variable | Area | Default | When to set |
|
| 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_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 |
|
| `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_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_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 |
|
| `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_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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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_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) |
|
| `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 |
|
| `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
|
## TTS Provider and Request Behavior
|
||||||
|
|
||||||
### API_BASE
|
### API_BASE
|
||||||
|
|
@ -230,6 +268,8 @@ Switches metadata/auth storage from SQLite to Postgres.
|
||||||
- Set: Postgres mode
|
- Set: Postgres mode
|
||||||
- Related docs: [Database](../configure/database)
|
- Related docs: [Database](../configure/database)
|
||||||
|
|
||||||
|
### Embedded SeaweedFS weed mini config
|
||||||
|
|
||||||
### USE_EMBEDDED_WEED_MINI
|
### USE_EMBEDDED_WEED_MINI
|
||||||
|
|
||||||
Controls embedded SeaweedFS startup.
|
Controls embedded SeaweedFS startup.
|
||||||
|
|
@ -252,6 +292,8 @@ Maximum seconds to wait for embedded SeaweedFS startup.
|
||||||
- Default: `20`
|
- Default: `20`
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
||||||
|
|
||||||
|
### S3 storage config
|
||||||
|
|
||||||
### S3_ACCESS_KEY_ID
|
### S3_ACCESS_KEY_ID
|
||||||
|
|
||||||
Access key for S3-compatible storage.
|
Access key for S3-compatible storage.
|
||||||
|
|
@ -346,12 +388,121 @@ Multiple library roots for server library import.
|
||||||
|
|
||||||
## Audio Tooling and Alignment
|
## 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`
|
- Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS.
|
||||||
- Required only for optional word-by-word highlighting
|
- 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
|
### 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.
|
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.
|
Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled.
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
- Default: `true` (enabled)
|
||||||
- Runtime key: `enableDocxConversion`
|
- 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.
|
Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings.
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
- Default: `true` (enabled)
|
||||||
- Runtime key: `enableDestructiveDeleteActions`
|
- 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.
|
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).
|
- Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected).
|
||||||
- Runtime key: `enableTtsProvidersTab`
|
- Runtime key: `enableTtsProvidersTab`
|
||||||
|
|
||||||
### NEXT_PUBLIC_ENABLE_USER_SIGNUPS
|
### RUNTIME_SEED_ENABLE_USER_SIGNUPS
|
||||||
|
|
||||||
Controls whether new user accounts can be created.
|
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.
|
- Existing users can still sign in.
|
||||||
- Runtime key: `enableUserSignups`
|
- 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.
|
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.
|
- When `false`, users can use per-user BYOK credentials for built-in providers.
|
||||||
- Runtime key: `restrictUserApiKeys`
|
- Runtime key: `restrictUserApiKeys`
|
||||||
|
|
||||||
### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER
|
### RUNTIME_SEED_DEFAULT_TTS_PROVIDER
|
||||||
|
|
||||||
Sets the default TTS provider for new users.
|
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**.
|
`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.
|
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`
|
- Runtime key: `changelogFeedUrl`
|
||||||
|
|
||||||
|
|
||||||
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT
|
### RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT
|
||||||
|
|
||||||
Controls whether audiobook export UI/actions are shown in the client.
|
Controls whether audiobook export UI/actions are shown in the client.
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
- Default: `true` (enabled)
|
||||||
- Affects export entry points in PDF/EPUB pages and document settings UI
|
- Affects export entry points in PDF/EPUB pages and document settings UI
|
||||||
- Runtime key: `enableAudiobookExport`
|
- 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
|
## 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
|
- [React](https://react.dev/) 19
|
||||||
- [TypeScript](https://www.typescriptlang.org/)
|
- [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [pnpm](https://pnpm.io/) workspaces monorepo
|
||||||
|
|
||||||
## Containerization and runtime
|
## 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)
|
- UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)
|
||||||
- Interactions: `react-dnd`, `react-dropzone`
|
- 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
|
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
|
||||||
- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
|
- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
|
||||||
|
- Audio playback: [Howler.js](https://howlerjs.com/)
|
||||||
|
- Notifications: `react-hot-toast`
|
||||||
- Document rendering:
|
- Document rendering:
|
||||||
- PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/)
|
- 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/)
|
- 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)
|
- 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)
|
- 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
|
## Next.js server
|
||||||
|
|
||||||
- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
|
- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
|
||||||
- State sync: request-based today (not realtime push updates)
|
- 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`)
|
- 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
|
- 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
|
- 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
|
- 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
|
## Tooling and testing
|
||||||
|
|
||||||
|
|
@ -42,3 +67,4 @@ title: Stack
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- [Playwright](https://playwright.dev/) end-to-end tests
|
- [Playwright](https://playwright.dev/) end-to-end tests
|
||||||
- Drizzle migration/generation scripts
|
- Drizzle migration/generation scripts
|
||||||
|
- [Docusaurus](https://docusaurus.io/) documentation site (`docs-site/`)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ const sidebars: SidebarsConfig = {
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
label: '🚀 Deploy',
|
label: '🚀 Deploy',
|
||||||
items: ['deploy/local-development', 'deploy/vercel-deployment'],
|
items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'category',
|
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.
|
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
|
### 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,
|
"when": 1778644075378,
|
||||||
"tag": "0004_admin_panel",
|
"tag": "0004_admin_panel",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1778644075081,
|
||||||
"tag": "0004_admin_panel",
|
"tag": "0004_admin_panel",
|
||||||
"breakpoints": true
|
"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,
|
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 = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...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;
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -30,7 +38,8 @@ const nextConfig: NextConfig = {
|
||||||
canvas: '@napi-rs/canvas',
|
canvas: '@napi-rs/canvas',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
|
transpilePackages: [],
|
||||||
|
serverExternalPackages,
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
'/api/audiobook': [
|
'/api/audiobook': [
|
||||||
'./node_modules/ffmpeg-static/ffmpeg',
|
'./node_modules/ffmpeg-static/ffmpeg',
|
||||||
|
|
@ -38,7 +47,7 @@ const nextConfig: NextConfig = {
|
||||||
'/api/audiobook/chapter': [
|
'/api/audiobook/chapter': [
|
||||||
'./node_modules/ffmpeg-static/ffmpeg',
|
'./node_modules/ffmpeg-static/ffmpeg',
|
||||||
],
|
],
|
||||||
'/api/whisper': [
|
'/api/tts/segments/ensure': [
|
||||||
'./node_modules/ffmpeg-static/ffmpeg',
|
'./node_modules/ffmpeg-static/ffmpeg',
|
||||||
],
|
],
|
||||||
'/api/documents/blob/preview/ensure': [
|
'/api/documents/blob/preview/ensure': [
|
||||||
|
|
@ -51,6 +60,24 @@ const nextConfig: NextConfig = {
|
||||||
'./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
|
'./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;
|
export default nextConfig;
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -7,6 +7,7 @@
|
||||||
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
||||||
"dev:raw": "next dev --turbopack -p 3003",
|
"dev:raw": "next dev --turbopack -p 3003",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
|
||||||
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
|
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
|
||||||
"start:raw": "next start -p 3003",
|
"start:raw": "next start -p 3003",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|
@ -20,12 +21,18 @@
|
||||||
"docs:build": "pnpm --dir docs-site build",
|
"docs:build": "pnpm --dir docs-site build",
|
||||||
"docs:serve": "pnpm --dir docs-site serve",
|
"docs:serve": "pnpm --dir docs-site serve",
|
||||||
"docs:version": "pnpm --dir docs-site docusaurus docs:version",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1045.0",
|
"@aws-sdk/client-s3": "^3.1045.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||||
"@headlessui/react": "^2.2.10",
|
"@headlessui/react": "^2.2.10",
|
||||||
|
"@huggingface/tokenizers": "^0.1.3",
|
||||||
"@napi-rs/canvas": "^0.1.100",
|
"@napi-rs/canvas": "^0.1.100",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
|
@ -48,9 +55,12 @@
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lru-cache": "^11.3.6",
|
"lru-cache": "^11.3.6",
|
||||||
"next": "^15.5.18",
|
"next": "^15.5.18",
|
||||||
|
"onnxruntime-node": "^1.26.0",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"pdfjs-dist": "4.8.69",
|
"pdfjs-dist": "4.8.69",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'dotenv/config';
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
|
tsconfig: './tsconfig.json',
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
outputDir: './tests/results',
|
outputDir: './tests/results',
|
||||||
globalTeardown: './tests/global-teardown.ts',
|
globalTeardown: './tests/global-teardown.ts',
|
||||||
|
|
@ -29,7 +30,7 @@ export default defineConfig({
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
// Disable auth rate limiting for tests to support parallel workers creating sessions
|
// 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',
|
url: 'http://localhost:3003',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120 * 1000,
|
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:
|
packages:
|
||||||
- .
|
- .
|
||||||
|
- compute/*
|
||||||
strictDepBuilds: false
|
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@napi-rs/canvas': true
|
'@napi-rs/canvas': true
|
||||||
|
|
@ -11,10 +10,13 @@ allowBuilds:
|
||||||
es5-ext: false
|
es5-ext: false
|
||||||
esbuild: false
|
esbuild: false
|
||||||
ffmpeg-static: true
|
ffmpeg-static: true
|
||||||
|
onnxruntime-node: true
|
||||||
sharp: false
|
sharp: false
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
lodash: ^4.17.23
|
|
||||||
'@xmldom/xmldom': ^0.9.8
|
|
||||||
'@types/localforage': npm:empty-module@0.0.2
|
'@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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasNatsBinary() {
|
||||||
|
const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' });
|
||||||
|
if (probe.error) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForEndpoint(url, timeoutSeconds) {
|
async function waitForEndpoint(url, timeoutSeconds) {
|
||||||
const waitMs = Math.max(1, timeoutSeconds) * 1000;
|
const waitMs = Math.max(1, timeoutSeconds) * 1000;
|
||||||
const deadline = Date.now() + waitMs;
|
const deadline = Date.now() + waitMs;
|
||||||
|
|
@ -181,19 +187,20 @@ function spawnMainCommand(command, env) {
|
||||||
const exitPromise = new Promise((resolve) => {
|
const exitPromise = new Promise((resolve) => {
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
console.error('Failed to launch command:', error);
|
console.error('Failed to launch command:', error);
|
||||||
resolve(1);
|
resolve({ code: 1, signal: null, launchError: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
|
console.error(`Main command exit event: code=${code ?? 'null'} signal=${signal ?? 'null'}.`);
|
||||||
if (typeof code === 'number') {
|
if (typeof code === 'number') {
|
||||||
resolve(code);
|
resolve({ code, signal: null, launchError: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (signal) {
|
if (signal) {
|
||||||
resolve(1);
|
resolve({ code: 1, signal, launchError: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(0);
|
resolve({ code: 0, signal: null, launchError: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -311,12 +318,23 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeEnv = { ...process.env };
|
const runtimeEnv = { ...process.env };
|
||||||
|
runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty');
|
||||||
let weedProc = null;
|
let weedProc = null;
|
||||||
let weedExitPromise = Promise.resolve();
|
let weedExitPromise = Promise.resolve();
|
||||||
|
let natsProc = null;
|
||||||
|
let natsExitPromise = Promise.resolve();
|
||||||
|
let workerProc = null;
|
||||||
|
let workerExitPromise = Promise.resolve();
|
||||||
let appProc = null;
|
let appProc = null;
|
||||||
let shutdownPromise = null;
|
let shutdownPromise = null;
|
||||||
|
let isShuttingDown = false;
|
||||||
|
let fatalExitScheduled = false;
|
||||||
let stopWeedStdoutForward = () => { };
|
let stopWeedStdoutForward = () => { };
|
||||||
let stopWeedStderrForward = () => { };
|
let stopWeedStderrForward = () => { };
|
||||||
|
let stopNatsStdoutForward = () => { };
|
||||||
|
let stopNatsStderrForward = () => { };
|
||||||
|
let stopWorkerStdoutForward = () => { };
|
||||||
|
let stopWorkerStderrForward = () => { };
|
||||||
let didExit = false;
|
let didExit = false;
|
||||||
|
|
||||||
const exitOnce = (code) => {
|
const exitOnce = (code) => {
|
||||||
|
|
@ -325,16 +343,38 @@ async function main() {
|
||||||
process.exit(code);
|
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') => {
|
const shutdown = async (signal = 'SIGTERM') => {
|
||||||
if (shutdownPromise) return shutdownPromise;
|
if (shutdownPromise) return shutdownPromise;
|
||||||
|
isShuttingDown = true;
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
terminateChild(appProc, signal, 4000),
|
terminateChild(appProc, signal, 4000),
|
||||||
|
terminateChild(workerProc, 'SIGTERM', 4000),
|
||||||
|
terminateChild(natsProc, 'SIGTERM', 4000),
|
||||||
terminateChild(weedProc, 'SIGTERM', 4000),
|
terminateChild(weedProc, 'SIGTERM', 4000),
|
||||||
]);
|
]);
|
||||||
await weedExitPromise;
|
await weedExitPromise;
|
||||||
|
await natsExitPromise;
|
||||||
|
await workerExitPromise;
|
||||||
stopWeedStdoutForward();
|
stopWeedStdoutForward();
|
||||||
stopWeedStderrForward();
|
stopWeedStderrForward();
|
||||||
|
stopNatsStdoutForward();
|
||||||
|
stopNatsStderrForward();
|
||||||
|
stopWorkerStdoutForward();
|
||||||
|
stopWorkerStderrForward();
|
||||||
})();
|
})();
|
||||||
return shutdownPromise;
|
return shutdownPromise;
|
||||||
};
|
};
|
||||||
|
|
@ -374,7 +414,12 @@ async function main() {
|
||||||
const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20;
|
const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20;
|
||||||
const launchWeed = (endpointUrl) => {
|
const launchWeed = (endpointUrl) => {
|
||||||
const parsedEndpoint = parseS3Endpoint(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}`);
|
weedArgs.push(`-s3.port=${parsedEndpoint.port}`);
|
||||||
if (runningInDocker) {
|
if (runningInDocker) {
|
||||||
weedArgs.push('-ip.bind=0.0.0.0');
|
weedArgs.push('-ip.bind=0.0.0.0');
|
||||||
|
|
@ -389,13 +434,13 @@ async function main() {
|
||||||
weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined);
|
weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined);
|
||||||
|
|
||||||
weedProc.on('exit', (code, signal) => {
|
weedProc.on('exit', (code, signal) => {
|
||||||
|
if (isShuttingDown) return;
|
||||||
if (typeof code === 'number' && code !== 0) {
|
if (typeof code === 'number' && code !== 0) {
|
||||||
console.error(`Embedded weed mini exited with code ${code}.`);
|
console.error(`Embedded weed mini exited with code ${code}.`);
|
||||||
return;
|
} else if (signal) {
|
||||||
}
|
|
||||||
if (signal) {
|
|
||||||
console.error(`Embedded weed mini exited due to signal ${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);
|
const { child, exitPromise } = spawnMainCommand(command, runtimeEnv);
|
||||||
appProc = child;
|
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');
|
await shutdown('SIGTERM');
|
||||||
exitOnce(exitCode);
|
exitOnce(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,13 @@ import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
import { DocumentProvider } from '@/contexts/DocumentContext';
|
import { DocumentProvider } from '@/contexts/DocumentContext';
|
||||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext';
|
||||||
|
|
||||||
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<DocumentProvider>
|
<DocumentProvider>
|
||||||
<>
|
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||||
{children}
|
|
||||||
<DexieMigrationModal />
|
|
||||||
</>
|
|
||||||
</DocumentProvider>
|
</DocumentProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { resolveDocumentId } from '@/lib/client/dexie';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
||||||
import { useEpubDocument } from './useEpubDocument';
|
import { useEpubDocument } from './useEpubDocument';
|
||||||
|
|
||||||
export default function EPUBPage() {
|
export default function EPUBPage() {
|
||||||
|
|
@ -102,6 +103,8 @@ export default function EPUBPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument, isLoading]);
|
}, [loadDocument, isLoading]);
|
||||||
|
|
||||||
|
useUnmountCleanupRef(clearCurrDoc);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
|
|
@ -111,7 +114,9 @@ export default function EPUBPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
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,
|
// compute max horizontal padding while preserving a minimum readable width,
|
||||||
// but still allow some padding on small screens
|
// but still allow some padding on small screens
|
||||||
|
|
@ -122,9 +127,15 @@ export default function EPUBPage() {
|
||||||
setMaxPadPx(maxPad);
|
setMaxPadPx(maxPad);
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
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
|
// Nudge EPUB renderer to reflow on horizontal padding changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -162,7 +173,6 @@ export default function EPUBPage() {
|
||||||
<p className="text-red-500 mb-4">{error}</p>
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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">
|
<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={
|
left={
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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"
|
aria-label="Back to documents"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
providerRef,
|
providerRef,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
smartSentenceSplitting,
|
|
||||||
epubTheme,
|
epubTheme,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
@ -239,22 +238,13 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const leadingPreview = collectLeadingContextFromRange(range);
|
const leadingPreview = collectLeadingContextFromRange(range);
|
||||||
const continuationPreview = collectContinuationFromRange(range);
|
const continuationPreview = collectContinuationFromRange(range);
|
||||||
|
|
||||||
if (smartSentenceSplitting) {
|
setTTSText(textContent, {
|
||||||
setTTSText(textContent, {
|
shouldPause,
|
||||||
shouldPause,
|
location: start.cfi,
|
||||||
location: start.cfi,
|
previousText: leadingPreview,
|
||||||
previousText: leadingPreview,
|
nextLocation: end.cfi,
|
||||||
nextLocation: end.cfi,
|
nextText: continuationPreview
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setCurrDocText(textContent);
|
setCurrDocText(textContent);
|
||||||
|
|
||||||
return textContent;
|
return textContent;
|
||||||
|
|
@ -262,7 +252,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
console.error('Error extracting EPUB text:', error);
|
console.error('Error extracting EPUB text:', error);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]);
|
}, [setRenderedTextMaps, setTTSText]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a draft EPUB locator (typically `{ readerType: 'epub', location:
|
* 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 { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import { useHtmlDocument } from './useHtmlDocument';
|
import { useHtmlDocument } from './useHtmlDocument';
|
||||||
|
|
@ -102,6 +103,8 @@ export default function HTMLPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument, isLoading]);
|
}, [loadDocument, isLoading]);
|
||||||
|
|
||||||
|
useUnmountCleanupRef(clearCurrDoc);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
|
|
@ -111,7 +114,9 @@ export default function HTMLPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
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
|
// Adaptive minimum content width: allow some padding on narrow screens
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
|
|
@ -121,9 +126,15 @@ export default function HTMLPage() {
|
||||||
setMaxPadPx(maxPad);
|
setMaxPadPx(maxPad);
|
||||||
};
|
};
|
||||||
compute();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
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 (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
|
|
@ -149,7 +160,6 @@ export default function HTMLPage() {
|
||||||
<p className="text-red-500 mb-4">{error}</p>
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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">
|
<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={
|
left={
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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"
|
aria-label="Back to documents"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
providerRef,
|
providerRef,
|
||||||
smartSentenceSplitting,
|
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
|
|
@ -133,10 +132,9 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
createHtmlAudiobookSourceAdapter({
|
createHtmlAudiobookSourceAdapter({
|
||||||
blocks,
|
blocks,
|
||||||
isTxt,
|
isTxt,
|
||||||
smartSentenceSplitting,
|
|
||||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
}),
|
}),
|
||||||
[blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength],
|
[blocks, isTxt, ttsSegmentMaxBlockLength],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createFullAudioBook = useCallback(
|
const createFullAudioBook = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
|
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||||
{authEnabled && <ClaimDataPopup />}
|
|
||||||
<main className="flex-1 flex flex-col">{children}</main>
|
<main className="flex-1 flex flex-col">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
@ -19,6 +19,14 @@ import { resolveDocumentId } from '@/lib/client/dexie';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
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';
|
import { usePdfDocument } from './usePdfDocument';
|
||||||
|
|
||||||
// Dynamic import for client-side rendering only
|
// Dynamic import for client-side rendering only
|
||||||
|
|
@ -26,10 +34,12 @@ const PDFViewer = dynamic(
|
||||||
() => import('@/components/views/PDFViewer').then((module) => module.PDFViewer),
|
() => import('@/components/views/PDFViewer').then((module) => module.PDFViewer),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <DocumentSkeleton />
|
loading: () => null
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PARSE_LOADER_EXPAND_DELAY_MS = 320;
|
||||||
|
|
||||||
export default function PDFViewerPage() {
|
export default function PDFViewerPage() {
|
||||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -41,6 +51,13 @@ export default function PDFViewerPage() {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
parseStatus,
|
||||||
|
parseProgress,
|
||||||
|
documentSettings,
|
||||||
|
updateDocumentSettings,
|
||||||
|
parsedOverlayEnabled,
|
||||||
|
setParsedOverlayEnabled,
|
||||||
|
forceReparseParsedPdf,
|
||||||
createFullAudioBook: createPDFAudioBook,
|
createFullAudioBook: createPDFAudioBook,
|
||||||
regenerateChapter: regeneratePDFChapter,
|
regenerateChapter: regeneratePDFChapter,
|
||||||
} = pdfState;
|
} = pdfState;
|
||||||
|
|
@ -48,14 +65,29 @@ export default function PDFViewerPage() {
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isPdfViewerReady, setIsPdfViewerReady] = useState(false);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'audiobook' | 'segments'>(null);
|
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 [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const inFlightDocIdRef = useRef<string | null>(null);
|
const inFlightDocIdRef = useRef<string | null>(null);
|
||||||
const loadedDocIdRef = 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(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setIsPdfViewerReady(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setActiveSidebar(null);
|
setActiveSidebar(null);
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
|
|
@ -67,6 +99,7 @@ export default function PDFViewerPage() {
|
||||||
console.log('Loading new document (from page.tsx)');
|
console.log('Loading new document (from page.tsx)');
|
||||||
let didRedirect = false;
|
let didRedirect = false;
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
|
let loadSucceeded = false;
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
|
|
@ -89,8 +122,20 @@ export default function PDFViewerPage() {
|
||||||
startedLoad = true;
|
startedLoad = true;
|
||||||
inFlightDocIdRef.current = resolved;
|
inFlightDocIdRef.current = resolved;
|
||||||
stop(); // Reset TTS when loading new document
|
stop(); // Reset TTS when loading new document
|
||||||
await setCurrentDocument(resolved);
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
loadedDocIdRef.current = resolved;
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading document:', err);
|
console.error('Error loading document:', err);
|
||||||
setError('Failed to load document');
|
setError('Failed to load document');
|
||||||
|
|
@ -98,7 +143,7 @@ export default function PDFViewerPage() {
|
||||||
if (startedLoad) {
|
if (startedLoad) {
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
}
|
}
|
||||||
if (!didRedirect && startedLoad) {
|
if (!didRedirect && startedLoad && loadSucceeded) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +153,30 @@ export default function PDFViewerPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [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)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
|
|
@ -117,24 +186,55 @@ export default function PDFViewerPage() {
|
||||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const h = Math.max(0, vh - headerH - ttsH);
|
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();
|
compute();
|
||||||
|
const settleT1 = window.setTimeout(compute, 0);
|
||||||
|
const settleT2 = window.setTimeout(compute, 120);
|
||||||
window.addEventListener('resize', compute);
|
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 handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
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 (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
settings: AudiobookGenerationSettings
|
settings: AudiobookGenerationSettings
|
||||||
) => {
|
) => {
|
||||||
|
if (!isParseReady) {
|
||||||
|
throw new Error('PDF parsing is not ready yet.');
|
||||||
|
}
|
||||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
|
||||||
}, [createPDFAudioBook, id]);
|
}, [createPDFAudioBook, id, isParseReady]);
|
||||||
|
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
|
|
@ -142,8 +242,11 @@ export default function PDFViewerPage() {
|
||||||
settings: AudiobookGenerationSettings,
|
settings: AudiobookGenerationSettings,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
|
if (!isParseReady) {
|
||||||
|
throw new Error('PDF parsing is not ready yet.');
|
||||||
|
}
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings);
|
return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings);
|
||||||
}, [regeneratePDFChapter]);
|
}, [regeneratePDFChapter, isParseReady]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -151,7 +254,7 @@ export default function PDFViewerPage() {
|
||||||
<p className="text-red-500 mb-4">{error}</p>
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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">
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
left={
|
left={
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
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"
|
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"
|
aria-label="Back to documents"
|
||||||
>
|
>
|
||||||
|
|
@ -199,15 +413,21 @@ export default function PDFViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
<div className="relative overflow-hidden" style={{ height: containerHeight }}>
|
||||||
|
{isParseReady ? (
|
||||||
{isLoading ? (
|
<div className={isPdfViewerReady ? 'h-full' : 'h-full opacity-0 pointer-events-none'}>
|
||||||
<div className="p-4">
|
<PDFViewer
|
||||||
<DocumentSkeleton />
|
zoomLevel={zoomLevel}
|
||||||
|
onDocumentReady={() => setIsPdfViewerReady(true)}
|
||||||
|
pdfState={pdfState}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<PDFViewer zoomLevel={zoomLevel} pdfState={pdfState} />
|
{isLoading || !isParseReady || !isPdfViewerReady ? (
|
||||||
)}
|
<div className="absolute inset-0 z-10" data-testid="pdf-status-loader">
|
||||||
|
{renderPdfStatusLoader()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{canExportAudiobook && (
|
{canExportAudiobook && (
|
||||||
<AudiobookExportModal
|
<AudiobookExportModal
|
||||||
|
|
@ -226,12 +446,41 @@ export default function PDFViewerPage() {
|
||||||
<RateLimitBanner />
|
<RateLimitBanner />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isParseReady ? (
|
||||||
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||||
)}
|
) : null}
|
||||||
<DocumentSettings
|
<DocumentSettings
|
||||||
isOpen={activeSidebar === 'settings'}
|
isOpen={activeSidebar === 'settings'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
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
|
<SegmentsSidebar
|
||||||
isOpen={activeSidebar === 'segments'}
|
isOpen={activeSidebar === 'segments'}
|
||||||
|
|
|
||||||
|
|
@ -18,26 +18,41 @@ import {
|
||||||
|
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
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 { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
||||||
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
|
||||||
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
import { ensureCachedDocument } from '@/lib/client/cache/documents';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
clearWordHighlights,
|
clearWordHighlights,
|
||||||
highlightWordIndex,
|
highlightWordIndex,
|
||||||
} from '@/lib/client/pdf';
|
} 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 {
|
import type {
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||||
import { clampSegmentPreloadDepth } from '@/types/config';
|
import { clampSegmentPreloadDepth } from '@/types/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,12 +67,29 @@ export interface PdfDocumentState {
|
||||||
currDocPage: number;
|
currDocPage: number;
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
pdfDocument: PDFDocumentProxy | 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;
|
clearCurrDoc: () => void;
|
||||||
|
|
||||||
// PDF functionality
|
// PDF functionality
|
||||||
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
|
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;
|
clearHighlights: () => void;
|
||||||
clearWordHighlights: () => void;
|
clearWordHighlights: () => void;
|
||||||
highlightWordIndex: (
|
highlightWordIndex: (
|
||||||
|
|
@ -84,9 +116,6 @@ export interface PdfDocumentState {
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
|
|
||||||
const EMPTY_TEXT_MAX_RETRIES = 6;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main PDF route hook.
|
* Main PDF route hook.
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,14 +130,9 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
registerVisualPageChangeHandler,
|
registerVisualPageChangeHandler,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
headerMargin,
|
|
||||||
footerMargin,
|
|
||||||
leftMargin,
|
|
||||||
rightMargin,
|
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
providerRef,
|
providerRef,
|
||||||
smartSentenceSplitting,
|
|
||||||
segmentPreloadDepthPages,
|
segmentPreloadDepthPages,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
@ -119,18 +143,18 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
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 [isAudioCombining] = useState(false);
|
||||||
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
||||||
pdfDocument,
|
parsed: parsedDocument ?? undefined,
|
||||||
margins: {
|
settings: documentSettings,
|
||||||
header: headerMargin,
|
|
||||||
footer: footerMargin,
|
|
||||||
left: leftMargin,
|
|
||||||
right: rightMargin,
|
|
||||||
},
|
|
||||||
smartSentenceSplitting,
|
|
||||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
}), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]);
|
}), [parsedDocument, documentSettings, ttsSegmentMaxBlockLength]);
|
||||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||||
|
|
||||||
|
|
@ -139,11 +163,129 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
const pdfDocGenerationRef = useRef(0);
|
const pdfDocGenerationRef = useRef(0);
|
||||||
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
|
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
|
||||||
const loadSeqRef = useRef(0);
|
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.
|
// Guards for setCurrentDocument to prevent stale loads from overwriting newer selections.
|
||||||
const docLoadSeqRef = useRef(0);
|
const docLoadSeqRef = useRef(0);
|
||||||
const docLoadAbortRef = useRef<AbortController | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
pdfDocumentRef.current = pdfDocument;
|
pdfDocumentRef.current = pdfDocument;
|
||||||
|
|
@ -167,7 +309,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and processes text from the current document page
|
* 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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
|
|
@ -179,20 +321,18 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
const seq = ++loadSeqRef.current;
|
const seq = ++loadSeqRef.current;
|
||||||
const pageNumber = currDocPageNumber;
|
const pageNumber = currDocPageNumber;
|
||||||
|
|
||||||
const existingRetry = emptyRetryRef.current;
|
const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined =>
|
||||||
if (existingRetry?.timer) {
|
parsedDocument?.pages.find((page) => page.pageNumber === pageNum);
|
||||||
clearTimeout(existingRetry.timer);
|
|
||||||
}
|
|
||||||
emptyRetryRef.current =
|
|
||||||
existingRetry && existingRetry.page === pageNumber
|
|
||||||
? { ...existingRetry, timer: null }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const margins = {
|
if (parseStatus !== 'ready' || !parsedDocument) {
|
||||||
header: headerMargin,
|
setCurrDocText(undefined);
|
||||||
footer: footerMargin,
|
setTTSText('', { location: currDocPageNumber });
|
||||||
left: leftMargin,
|
return;
|
||||||
right: rightMargin
|
}
|
||||||
|
|
||||||
|
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> => {
|
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
||||||
|
|
@ -209,7 +349,10 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
return cached;
|
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) {
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
throw new DOMException('Stale PDF extraction', 'AbortError');
|
throw new DOMException('Stale PDF extraction', 'AbortError');
|
||||||
|
|
@ -237,14 +380,15 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
|
prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve<string | undefined>(undefined),
|
||||||
...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)),
|
...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)),
|
||||||
]);
|
]);
|
||||||
const nextText = upcomingTexts[0];
|
const {
|
||||||
const additionalUpcoming = upcomingPageNumbers
|
nextText,
|
||||||
.slice(1)
|
nextSourceUnits,
|
||||||
.map((pageNum, idx) => ({
|
additionalUpcoming,
|
||||||
location: pageNum,
|
} = buildPdfPrefetchPayload(
|
||||||
text: upcomingTexts[idx + 1] || '',
|
upcomingPageNumbers,
|
||||||
}))
|
upcomingTexts,
|
||||||
.filter((item) => item.text.trim().length > 0);
|
sourceUnitsFromParsedPage,
|
||||||
|
);
|
||||||
|
|
||||||
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -253,40 +397,17 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
return;
|
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 === '') {
|
if (text !== currDocText || text === '') {
|
||||||
setCurrDocText(text);
|
setCurrDocText(text);
|
||||||
|
const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber);
|
||||||
setTTSText(text, {
|
setTTSText(text, {
|
||||||
location: currDocPageNumber,
|
location: currDocPageNumber,
|
||||||
previousText: prevText,
|
previousText: prevText,
|
||||||
nextLocation: nextPageNumber,
|
nextLocation: nextPageNumber,
|
||||||
nextText: nextText,
|
nextText: nextText,
|
||||||
|
nextSourceUnits,
|
||||||
upcomingLocations: additionalUpcoming,
|
upcomingLocations: additionalUpcoming,
|
||||||
|
...(sourceUnits.length > 0 ? { sourceUnits } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -300,11 +421,10 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
setTTSText,
|
setTTSText,
|
||||||
currDocText,
|
currDocText,
|
||||||
headerMargin,
|
|
||||||
footerMargin,
|
|
||||||
leftMargin,
|
|
||||||
rightMargin,
|
|
||||||
segmentPreloadDepthPages,
|
segmentPreloadDepthPages,
|
||||||
|
parsedDocument,
|
||||||
|
parseStatus,
|
||||||
|
documentSettings,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -324,7 +444,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
* @param {string} id - The unique identifier of the document to set
|
* @param {string} id - The unique identifier of the document to set
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
|
||||||
// --- race-condition guard ---
|
// --- race-condition guard ---
|
||||||
const seq = ++docLoadSeqRef.current;
|
const seq = ++docLoadSeqRef.current;
|
||||||
docLoadAbortRef.current?.abort();
|
docLoadAbortRef.current?.abort();
|
||||||
|
|
@ -337,10 +457,10 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
// or fast refresh.
|
// or fast refresh.
|
||||||
pdfDocGenerationRef.current += 1;
|
pdfDocGenerationRef.current += 1;
|
||||||
loadSeqRef.current += 1;
|
loadSeqRef.current += 1;
|
||||||
if (emptyRetryRef.current?.timer) {
|
parsePollAbortRef.current?.abort();
|
||||||
clearTimeout(emptyRetryRef.current.timer);
|
parsePollAbortRef.current = null;
|
||||||
}
|
parseSseCloseRef.current?.();
|
||||||
emptyRetryRef.current = null;
|
parseSseCloseRef.current = null;
|
||||||
pageTextCacheRef.current.clear();
|
pageTextCacheRef.current.clear();
|
||||||
setPdfDocument(undefined);
|
setPdfDocument(undefined);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
|
|
@ -348,35 +468,85 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
setCurrDocId(id);
|
setCurrDocId(id);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
|
setParsedDocument(null);
|
||||||
|
setParseStatus(null);
|
||||||
|
setParseProgress(null);
|
||||||
|
setActiveParseOpId(null);
|
||||||
|
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
|
||||||
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
||||||
if (seq !== docLoadSeqRef.current) return; // stale
|
if (seq !== docLoadSeqRef.current) return false; // stale
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.error('Document not found on server');
|
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 });
|
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') {
|
if (doc.type !== 'pdf') {
|
||||||
console.error('Document is not a PDF');
|
console.error('Document is not a PDF');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||||
// buffer passed into the worker; we always pass clones to react-pdf.
|
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||||
setCurrDocData(doc.data.slice(0));
|
setCurrDocData(doc.data.slice(0));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} 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);
|
console.error('Failed to get document:', error);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||||
if (docLoadAbortRef.current === controller) {
|
if (docLoadAbortRef.current === controller) {
|
||||||
docLoadAbortRef.current = null;
|
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
|
* Clears the current document state
|
||||||
|
|
@ -390,16 +560,21 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
docLoadSeqRef.current += 1;
|
docLoadSeqRef.current += 1;
|
||||||
docLoadAbortRef.current?.abort();
|
docLoadAbortRef.current?.abort();
|
||||||
docLoadAbortRef.current = null;
|
docLoadAbortRef.current = null;
|
||||||
if (emptyRetryRef.current?.timer) {
|
parsePollAbortRef.current?.abort();
|
||||||
clearTimeout(emptyRetryRef.current.timer);
|
parsePollAbortRef.current = null;
|
||||||
}
|
parseSseCloseRef.current?.();
|
||||||
emptyRetryRef.current = null;
|
parseSseCloseRef.current = null;
|
||||||
setCurrDocId(undefined);
|
setCurrDocId(undefined);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
setPdfDocument(undefined);
|
setPdfDocument(undefined);
|
||||||
|
setParsedDocument(null);
|
||||||
|
setParseStatus(null);
|
||||||
|
setParseProgress(null);
|
||||||
|
setActiveParseOpId(null);
|
||||||
|
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
pageTextCacheRef.current.clear();
|
pageTextCacheRef.current.clear();
|
||||||
stop();
|
stop();
|
||||||
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
|
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
|
||||||
|
|
@ -499,6 +674,14 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
parsedDocument,
|
||||||
|
parseStatus,
|
||||||
|
parseProgress,
|
||||||
|
documentSettings,
|
||||||
|
updateDocumentSettings,
|
||||||
|
parsedOverlayEnabled,
|
||||||
|
setParsedOverlayEnabled,
|
||||||
|
forceReparseParsedPdf,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
|
|
@ -518,6 +701,14 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
parsedDocument,
|
||||||
|
parseStatus,
|
||||||
|
parseProgress,
|
||||||
|
documentSettings,
|
||||||
|
updateDocumentSettings,
|
||||||
|
parsedOverlayEnabled,
|
||||||
|
setParsedOverlayEnabled,
|
||||||
|
forceReparseParsedPdf,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { auth } from '@/lib/server/auth/auth';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
|
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() {
|
export async function DELETE() {
|
||||||
if (!isAuthEnabled() || !auth) {
|
if (!isAuthEnabled() || !auth) {
|
||||||
|
|
@ -28,7 +30,13 @@ export async function DELETE() {
|
||||||
try {
|
try {
|
||||||
await deleteUserStorageData(session.user.id, testNamespace);
|
await deleteUserStorageData(session.user.id, testNamespace);
|
||||||
} catch (error) {
|
} 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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete account:', error);
|
serverLogger.error({
|
||||||
return NextResponse.json(
|
event: 'account.delete.failed',
|
||||||
{ error: 'Failed to delete account' },
|
error: errorToLog(error),
|
||||||
{ status: 500 }
|
}, '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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAdminContext } from '@/lib/server/auth/admin';
|
import { requireAdminContext } from '@/lib/server/auth/admin';
|
||||||
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
AdminProviderError,
|
AdminProviderError,
|
||||||
deleteAdminProvider,
|
deleteAdminProvider,
|
||||||
|
|
@ -39,10 +41,25 @@ export async function PUT(
|
||||||
return NextResponse.json({ provider: toMasked(updated) });
|
return NextResponse.json({ provider: toMasked(updated) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminProviderError) {
|
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);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminProviderError) {
|
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);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAdminContext } from '@/lib/server/auth/admin';
|
import { requireAdminContext } from '@/lib/server/auth/admin';
|
||||||
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
AdminProviderError,
|
AdminProviderError,
|
||||||
createAdminProvider,
|
createAdminProvider,
|
||||||
|
|
@ -44,9 +46,23 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json({ provider: toMasked(record) }, { status: 201 });
|
return NextResponse.json({ provider: toMasked(record) }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminProviderError) {
|
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);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
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 { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||||
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
import {
|
import {
|
||||||
deleteAudiobookObject,
|
deleteAudiobookObject,
|
||||||
getAudiobookObjectBuffer,
|
getAudiobookObjectBuffer,
|
||||||
|
|
@ -43,6 +44,7 @@ import {
|
||||||
coerceAudiobookGenerationSettings,
|
coerceAudiobookGenerationSettings,
|
||||||
type SharedProviderPolicyEntry,
|
type SharedProviderPolicyEntry,
|
||||||
} from '@/lib/server/audiobooks/settings';
|
} from '@/lib/server/audiobooks/settings';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -185,7 +187,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpeg.stderr.on('data', (data) => {
|
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) => {
|
ffmpeg.on('close', (code) => {
|
||||||
|
|
@ -325,8 +332,19 @@ export async function POST(request: NextRequest) {
|
||||||
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
});
|
});
|
||||||
if (!existingResult.settings) {
|
if (!existingResult.settings) {
|
||||||
console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId });
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
|
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);
|
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
|
||||||
existingSettingsNeedsMigration = existingResult.migrated;
|
existingSettingsNeedsMigration = existingResult.migrated;
|
||||||
|
|
@ -399,11 +417,14 @@ export async function POST(request: NextRequest) {
|
||||||
testNamespace,
|
testNamespace,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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,
|
bookId,
|
||||||
storageUserId,
|
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;
|
const provider = credResolved.provider;
|
||||||
if (!isBuiltInTtsProviderId(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 openApiKey = credResolved.apiKey || 'none';
|
||||||
const openApiBaseUrl = credResolved.baseUrl;
|
const openApiBaseUrl = credResolved.baseUrl;
|
||||||
|
|
@ -608,7 +632,12 @@ export async function POST(request: NextRequest) {
|
||||||
request.signal,
|
request.signal,
|
||||||
);
|
);
|
||||||
} catch (copyError) {
|
} 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(
|
await runFFmpeg(
|
||||||
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
|
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
|
||||||
request.signal,
|
request.signal,
|
||||||
|
|
@ -728,8 +757,14 @@ export async function POST(request: NextRequest) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Error processing audio chapter:', error);
|
serverLogger.error({
|
||||||
const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
|
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);
|
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||||
return response;
|
return response;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -823,8 +858,14 @@ export async function GET(request: NextRequest) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading chapter:', error);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 });
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting chapter:', error);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 });
|
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 { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
import {
|
||||||
audiobookPrefix,
|
audiobookPrefix,
|
||||||
deleteAudiobookObject,
|
deleteAudiobookObject,
|
||||||
|
|
@ -115,7 +117,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpeg.stderr.on('data', (data) => {
|
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) => {
|
ffmpeg.on('close', (code) => {
|
||||||
|
|
@ -282,7 +289,12 @@ export async function GET(request: NextRequest) {
|
||||||
request.signal,
|
request.signal,
|
||||||
);
|
);
|
||||||
} catch (copyError) {
|
} 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(
|
await runFFmpeg(
|
||||||
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
|
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
|
||||||
request.signal,
|
request.signal,
|
||||||
|
|
@ -311,7 +323,12 @@ export async function GET(request: NextRequest) {
|
||||||
request.signal,
|
request.signal,
|
||||||
);
|
);
|
||||||
} catch (copyError) {
|
} 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(
|
await runFFmpeg(
|
||||||
[
|
[
|
||||||
'-f',
|
'-f',
|
||||||
|
|
@ -360,8 +377,14 @@ export async function GET(request: NextRequest) {
|
||||||
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||||
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
|
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
|
||||||
}
|
}
|
||||||
console.error('Error creating full audiobook:', error);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
|
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 {
|
} finally {
|
||||||
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
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);
|
const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
|
||||||
return NextResponse.json({ success: true, existed: deleted > 0 });
|
return NextResponse.json({ success: true, existed: deleted > 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error resetting audiobook:', error);
|
serverLogger.error({
|
||||||
return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 });
|
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