diff --git a/README.md b/README.md index 824aac3..06704d0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights -- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints (OpenAI, DeepInfra, Kokoro, KittenTTS-FastAPI, Orpheus, custom). +- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (OpenAI, Replicate, DeepInfra, Kokoro, KittenTTS-FastAPI, Orpheus, custom). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. - ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. diff --git a/docs-site/docs/configure/tts-provider-guides/replicate.md b/docs-site/docs/configure/tts-provider-guides/replicate.md new file mode 100644 index 0000000..7d26b97 --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/replicate.md @@ -0,0 +1,39 @@ +--- +title: Replicate +--- + +Use Replicate's hosted TTS models as your provider. + +## Setup + +**Environment variables (recommended for deployment):** + +```env +API_KEY=r8_... +NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=replicate +NEXT_PUBLIC_DEFAULT_TTS_MODEL=google/gemini-3.1-flash-tts +``` + +**Or in-app via Settings -> TTS Provider:** + +1. Set provider to `Replicate`. +2. Enter your `API_KEY`. +3. Choose a model and voice. + +Settings modal values override env vars. See [TTS Providers](../tts-providers) for how the two layers interact. + +## Notes + +- Built-in Replicate models: + - `google/gemini-3.1-flash-tts` + - `minimax/speech-2.8-turbo` + - `qwen/qwen3-tts` + - `inworld/tts-1.5-mini` +- Native model speed is not available on all Replicate models; OpenReader hides/disables native speed controls where unsupported. +- TTS requests are sent from the server, not the browser. The API key is never exposed to clients. + +## References + +- [Replicate](https://replicate.com/explore) +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index 01783e6..b42bb1f 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -15,10 +15,17 @@ Set env vars as deployment-level defaults. Users (or you, in a single-user setup ## Providers - **OpenAI**: Cloud. Base URL pre-filled (`https://api.openai.com/v1`). API key required. +- **Replicate**: Cloud. Base URL managed internally by OpenReader. API key required. - **Deepinfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required. - **Custom OpenAI-Like**: Self-hosted or any custom endpoint. `API_BASE` must be set manually (typically ending in `/v1`). API key optional. -For `OpenAI` and `Deepinfra` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`. +For `OpenAI`, `Deepinfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`. + +## Built-in model catalogs + +- **Replicate** models: `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` +- **OpenAI** models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts` +- **Deepinfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags) ## Custom provider requirements @@ -36,6 +43,7 @@ TTS requests originate from the **Next.js server**, not the browser. `API_BASE` - [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi) - [KittenTTS-FastAPI](./tts-provider-guides/kitten-tts-fastapi) - [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi) +- [Replicate](./tts-provider-guides/replicate) - [DeepInfra](./tts-provider-guides/deepinfra) - [OpenAI](./tts-provider-guides/openai) - [Other](./tts-provider-guides/other) diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 1bede11..0d63344 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -49,8 +49,8 @@ We recommend setting these defaults for a production-like environment: - `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false`: Disables DOCX upload (requires external tools anyway) - `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false`: Hides destructive "Delete All" actions - `NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=false`: Hides the Settings -> TTS Provider section -- `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra`: Points default TTS to a scalable provider -- `NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M`: Uses a high-quality default model +- `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=replicate`: Points default TTS to a scalable provider +- `NEXT_PUBLIC_DEFAULT_TTS_MODEL=google/gemini-3.1-flash-tts`: Uses a high-quality default model - `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false`: Restricts usage to free models if no key is provided - `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true`: (Optional) Controls audiobook export UI - `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false`: (Optional) Controls word highlighting UI (requires timestamp backend) diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index b700e35..be015fe 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -8,7 +8,7 @@ import TabItem from '@theme/TabItem'; ## Prerequisites - A recent Docker version installed -- A TTS API server that OpenReader can reach (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI, DeepInfra, OpenAI, or equivalent) +- A TTS API server that OpenReader can reach (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI, Replicate, DeepInfra, OpenAI, or equivalent) :::note If you have suitable hardware, you can run Kokoro locally with Docker. See [Kokoro-FastAPI](./configure/tts-provider-guides/kokoro-fastapi). diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 4187ebf..b7f4a6f 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -8,7 +8,7 @@ OpenReader is an open source text-to-speech document reader built with Next.js. > Previously named **OpenReader-WebUI**. -It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), [KittenTTS-FastAPI](https://github.com/richardr1126/KittenTTS-FastAPI), and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI). +It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), [KittenTTS-FastAPI](https://github.com/richardr1126/KittenTTS-FastAPI), and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI). ## ✨ Highlights @@ -18,6 +18,7 @@ It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenA - [**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): `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, and `inworld/tts-1.5-mini` - [**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` - 🛜 **Server-side Document Storage** diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 0c525c8..2f1ff0b 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -379,7 +379,7 @@ Controls whether the **TTS Provider** section appears in the Settings modal. Sets the default TTS provider for new users. - Default: `custom-openai` - - Example values: `deepinfra`, `openai`, `custom-openai` + - Example values: `replicate`, `deepinfra`, `openai`, `custom-openai` ### NEXT_PUBLIC_DEFAULT_TTS_MODEL diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index a0bea2e..e285ea5 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -23,6 +23,7 @@ const sidebars: SidebarsConfig = { 'configure/tts-provider-guides/kokoro-fastapi', 'configure/tts-provider-guides/kitten-tts-fastapi', 'configure/tts-provider-guides/orpheus-fastapi', + 'configure/tts-provider-guides/replicate', 'configure/tts-provider-guides/deepinfra', 'configure/tts-provider-guides/openai', 'configure/tts-provider-guides/other', diff --git a/package.json b/package.json index bc7b101..e256f3a 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-pdf": "^9.2.1", "react-reader": "^2.0.15", "remark-gfm": "^4.0.1", + "replicate": "^1.4.0", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 120010e..e5e9f11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + replicate: + specifier: ^1.4.0 + version: 1.4.0 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -3588,6 +3591,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + replicate@1.4.0: + resolution: {integrity: sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w==} + engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -8028,6 +8035,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + replicate@1.4.0: + optionalDependencies: + readable-stream: 4.7.0 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index dbb08b3..61374bf 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -29,6 +29,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; +import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat } from '@/types/tts'; @@ -92,6 +93,12 @@ function s3NotConfiguredResponse(): NextResponse { ); } +function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings { + return supportsNativeModelSpeed(settings.ttsProvider, settings.ttsModel) + ? settings + : { ...settings, nativeSpeed: 1 }; +} + function chapterFileMimeType(format: TTSAudiobookFormat): string { return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; } @@ -300,18 +307,19 @@ export async function POST(request: NextRequest) { existingSettings = null; } - const incomingSettings = data.settings; + const incomingSettings = data.settings ? normalizeNativeSpeedForSettings(data.settings) : undefined; if (existingSettings && hasChapters && incomingSettings) { + const normalizedExistingSettings = normalizeNativeSpeedForSettings(existingSettings); const mismatch = - existingSettings.ttsProvider !== incomingSettings.ttsProvider || - existingSettings.ttsModel !== incomingSettings.ttsModel || - existingSettings.voice !== incomingSettings.voice || - existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || - existingSettings.postSpeed !== incomingSettings.postSpeed || - existingSettings.format !== incomingSettings.format || - (existingSettings.ttsInstructions || '') !== (incomingSettings.ttsInstructions || ''); + normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider || + normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel || + normalizedExistingSettings.voice !== incomingSettings.voice || + normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed || + normalizedExistingSettings.postSpeed !== incomingSettings.postSpeed || + normalizedExistingSettings.format !== incomingSettings.format || + (normalizedExistingSettings.ttsInstructions || '') !== (incomingSettings.ttsInstructions || ''); if (mismatch) { - return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 }); + return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 }); } } diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 43de629..7983393 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -54,9 +54,22 @@ function getUpstreamStatus(error: unknown): number | undefined { const rec = error as Record; if (typeof rec.status === 'number') return rec.status; if (typeof rec.statusCode === 'number') return rec.statusCode; + const response = rec.response as { status?: unknown } | undefined; + if (response && typeof response.status === 'number') return response.status; return undefined; } +function getUpstreamRetryAfterSeconds(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const rec = error as Record; + const response = rec.response as { headers?: { get?: (name: string) => string | null } } | undefined; + const retryAfterHeader = response?.headers?.get?.('retry-after'); + if (!retryAfterHeader) return undefined; + const parsed = Number(retryAfterHeader); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return Math.ceil(parsed); +} + export async function POST(req: NextRequest) { let providerForError: string | null = null; let didCreateDeviceIdCookie = false; @@ -221,14 +234,18 @@ export async function POST(req: NextRequest) { const upstreamStatus = getUpstreamStatus(error); if (upstreamStatus === 429) { + const retryAfterSeconds = getUpstreamRetryAfterSeconds(error); const problem: ProblemDetails = { type: PROBLEM_TYPES.upstreamRateLimited, title: 'Upstream rate limited', status: 429, - detail: 'The TTS provider is rate limiting requests. Please try again shortly.', + detail: retryAfterSeconds + ? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.` + : 'The TTS provider is rate limiting requests. Please try again shortly.', code: 'UPSTREAM_RATE_LIMIT', provider: providerForError ?? undefined, upstreamStatus, + retryAfterSeconds, instance: req.nextUrl.pathname, }; @@ -236,11 +253,16 @@ export async function POST(req: NextRequest) { status: 429, headers: { 'Content-Type': 'application/problem+json', + ...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}), }, }); } - console.warn('Error generating TTS:', error); + const statusHint = getUpstreamStatus(error); + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn( + `Error generating TTS${typeof statusHint === 'number' ? ` (upstream ${statusHint})` : ''}: ${errorMessage}`, + ); const errorBody: TTSError = { code: 'TTS_GENERATION_FAILED', message: 'Failed to generate audio', diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 52838ca..b029a82 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -11,7 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; -import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; +import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, @@ -73,6 +73,8 @@ export function AudiobookExportModal({ const formatSpeed = useCallback((speed: number) => { return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1); }, []); + const nativeSpeedSupported = useMemo(() => supportsNativeModelSpeed(ttsProvider, ttsModel), [ttsProvider, ttsModel]); + const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1; const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; @@ -113,12 +115,12 @@ export function AudiobookExportModal({ ttsProvider, ttsModel, voice: nextVoice, - nativeSpeed, + nativeSpeed: effectiveNativeSpeed, postSpeed, format, ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined, }; - }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, nativeSpeed, postSpeed, format]); + }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format]); const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { @@ -529,11 +531,15 @@ export function AudiobookExportModal({
{savedSettings.format.toUpperCase()}
-
-
-
Native speed
-
{formatSpeed(savedSettings.nativeSpeed)}x
-
+
+
+
Native speed
+
+ {supportsNativeModelSpeed(savedSettings.ttsProvider, savedSettings.ttsModel) + ? `${formatSpeed(savedSettings.nativeSpeed)}x` + : 'Not supported'} +
+
Post speed
{formatSpeed(savedSettings.postSpeed)}x
@@ -616,29 +622,39 @@ export function AudiobookExportModal({
- {/* Speed controls */} -
-
-
- - {formatSpeed(nativeSpeed)}x -
- setNativeSpeed(parseFloat(e.target.value))} - className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0" - /> -
- 0.5x - 3x -
-
+ {/* Speed controls */} +
+ {!nativeSpeedSupported && ( +
+ Native model speed is not available for this model. +
+ )} -
+ {nativeSpeedSupported && ( + <> +
+
+ + {formatSpeed(nativeSpeed)}x +
+ setNativeSpeed(parseFloat(e.target.value))} + className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0" + /> +
+ 0.5x + 3x +
+
+ +
+ + )}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index dfe2e5a..bf01a68 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -491,7 +491,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { {/* Content */} -
+
{/* API Section */} {activeSection === 'api' && (
@@ -507,6 +507,9 @@ export function SettingsModal({ className = '' }: { className?: string }) { } else if (provider.id === 'custom-openai') { setModelValue('kokoro'); setLocalBaseUrl(''); + } else if (provider.id === 'replicate') { + setModelValue('google/gemini-3.1-flash-tts'); + setLocalBaseUrl(''); } else if (provider.id === 'deepinfra') { setModelValue('hexgrad/Kokoro-82M'); setLocalBaseUrl('https://api.deepinfra.com/v1/openai'); @@ -528,7 +531,10 @@ export function SettingsModal({ className = '' }: { className?: string }) { leaveFrom="opacity-100" leaveTo="opacity-0" > - + {ttsProviders.map((provider) => (
- {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( + {localTTSProvider !== 'replicate' && (localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (