diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts index e8b43b2..ebd947e 100644 --- a/compute/core/src/index.ts +++ b/compute/core/src/index.ts @@ -17,3 +17,6 @@ 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'; diff --git a/compute/core/src/pdf/model.ts b/compute/core/src/pdf/model.ts index cdc0aa3..ca09540 100644 --- a/compute/core/src/pdf/model.ts +++ b/compute/core/src/pdf/model.ts @@ -1,14 +1,15 @@ 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'; -import manifest from './assets/manifest.json'; const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main'; const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL'; const 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'); @@ -16,6 +17,20 @@ export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.prep 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 | null = null; async function sha256Hex(filePath: string): Promise { diff --git a/compute/core/src/whisper/model.ts b/compute/core/src/whisper/model.ts index d24d009..100bc6f 100644 --- a/compute/core/src/whisper/model.ts +++ b/compute/core/src/whisper/model.ts @@ -1,13 +1,14 @@ 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'; -import manifest from './assets/manifest.json'; const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped'); const STATIC_LICENSE_PATH = path.join(MODULE_DIR, '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'); @@ -70,7 +71,13 @@ export interface WhisperStaticArtifactSpec { export type WhisperFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; -const MANIFEST_FILES = manifest.files as ManifestEntry[]; +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'); diff --git a/next.config.ts b/next.config.ts index 41721b8..7beea83 100644 --- a/next.config.ts +++ b/next.config.ts @@ -25,12 +25,12 @@ const computeModeRaw = (process.env.COMPUTE_MODE || 'local').trim().toLowerCase( const computeMode = computeModeRaw === 'none' || computeModeRaw === 'worker' || computeModeRaw === 'local' ? computeModeRaw : 'local'; -const computeLocal = computeMode === 'local'; +const bundleWorkerCompute = computeMode === 'worker'; const serverExternalPackages = [ '@napi-rs/canvas', 'better-sqlite3', 'ffmpeg-static', - ...(computeLocal ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), + ...(!bundleWorkerCompute ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), ]; const nextConfig: NextConfig = { @@ -48,7 +48,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - transpilePackages: computeLocal ? ['@openreader/compute-core'] : [], + transpilePackages: !bundleWorkerCompute ? ['@openreader/compute-core'] : [], serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ @@ -73,7 +73,7 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { '/*': [ './docstore/**/*', - ...(!computeLocal + ...(bundleWorkerCompute ? [ './node_modules/onnxruntime-node/**/*', './node_modules/@huggingface/tokenizers/**/*', @@ -82,15 +82,15 @@ const nextConfig: NextConfig = { ], }, webpack: (config, { isServer }) => { - if (isServer) { + if (isServer && bundleWorkerCompute) { config.plugins = config.plugins || []; config.plugins.push( new DefinePlugin({ - __OPENREADER_COMPUTE_MODE__: JSON.stringify(computeMode), + __OPENREADER_COMPUTE_MODE__: JSON.stringify('worker'), }), ); } - if (isServer && !computeLocal) { + if (isServer && bundleWorkerCompute) { const workerComputeEntry = path.resolve(__dirname, 'src/lib/server/compute/index.worker.ts'); const computeIndexTs = path.resolve(__dirname, 'src/lib/server/compute/index.ts'); const computeIndexNoExt = path.resolve(__dirname, 'src/lib/server/compute/index'); diff --git a/tests/unit/whisper-alignment-mapping.spec.ts b/tests/unit/whisper-alignment-mapping.spec.ts index 9b08eb4..5f91973 100644 --- a/tests/unit/whisper-alignment-mapping.spec.ts +++ b/tests/unit/whisper-alignment-mapping.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { mapWordsToSentenceOffsets, -} from '../../compute/core/src/whisper/alignment-map'; +} from '@openreader/compute-core'; test.describe('whisper alignment mapping', () => { test('maps words to sentence offsets with punctuation and repeated spaces', () => { diff --git a/tests/unit/whisper-alignment-smoke.spec.ts b/tests/unit/whisper-alignment-smoke.spec.ts deleted file mode 100644 index 98f5fa9..0000000 --- a/tests/unit/whisper-alignment-smoke.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { readFile } from 'fs/promises'; -import path from 'path'; - -test.describe('whisper alignment smoke', () => { - test('runs ONNX alignment end-to-end without decoder reshape errors', async () => { - test.setTimeout(180000); - - const audioPath = path.join(process.cwd(), 'tests/files/sample.mp3'); - const audioBytes = await readFile(audioPath); - const buffer = audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength); - - const { runWhisperAlignmentFromAudioBuffer } = await import('@openreader/compute-core/local-runtime'); - const { alignments } = await runWhisperAlignmentFromAudioBuffer({ - audioBuffer: buffer, - text: 'This is a sample sentence used to validate whisper alignment execution.', - lang: 'en', - }); - - expect(alignments.length).toBe(1); - expect(Array.isArray(alignments[0].words)).toBe(true); - expect(alignments[0].words.length).toBeGreaterThan(0); - - let maxEnd = 0; - let positiveDurationWordCount = 0; - for (const word of alignments[0].words) { - expect(Number.isFinite(word.startSec)).toBe(true); - expect(Number.isFinite(word.endSec)).toBe(true); - expect(word.endSec).toBeGreaterThanOrEqual(word.startSec); - maxEnd = Math.max(maxEnd, word.endSec); - if (word.endSec > word.startSec) positiveDurationWordCount += 1; - } - expect(maxEnd).toBeLessThanOrEqual(10.2); - expect(positiveDurationWordCount).toBeGreaterThan(0); - }); -}); diff --git a/tests/unit/whisper-ensure-model.spec.ts b/tests/unit/whisper-ensure-model.spec.ts deleted file mode 100644 index e4668a2..0000000 --- a/tests/unit/whisper-ensure-model.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('whisper model bootstrap via local-runtime', () => { - test('is idempotent across concurrent calls', async () => { - test.setTimeout(180000); - const { ensureComputeModels } = await import('@openreader/compute-core/local-runtime'); - await expect( - Promise.all([ - ensureComputeModels(), - ensureComputeModels(), - ensureComputeModels(), - ]), - ).resolves.toBeDefined(); - }); -}); diff --git a/tests/unit/whisper-spectral.spec.ts b/tests/unit/whisper-spectral.spec.ts index 5b35e2f..90857a2 100644 --- a/tests/unit/whisper-spectral.spec.ts +++ b/tests/unit/whisper-spectral.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { buildGoertzelCoefficients, goertzelPower } from '../../compute/core/src/whisper/spectral'; +import { buildGoertzelCoefficients, goertzelPower } from '@openreader/compute-core'; function dftPower(samples: Float32Array, k: number): number { const n = samples.length; diff --git a/tests/unit/whisper-token-timestamps.spec.ts b/tests/unit/whisper-token-timestamps.spec.ts index 68660b6..a584d6f 100644 --- a/tests/unit/whisper-token-timestamps.spec.ts +++ b/tests/unit/whisper-token-timestamps.spec.ts @@ -3,7 +3,7 @@ import * as ort from 'onnxruntime-node'; import { buildWordsFromTimestampedTokens, extractTokenStartTimestamps, -} from '../../compute/core/src/whisper/token-timestamps'; +} from '@openreader/compute-core'; test.describe('whisper token timestamp alignment', () => { test('extracts monotonic token timestamps from cross-attention maps', () => {