diff --git a/__tests__/integration/models/pixel10ImageGenCpuPath.test.ts b/__tests__/integration/models/pixel10ImageGenCpuPath.test.ts new file mode 100644 index 000000000..e4fa0c96a --- /dev/null +++ b/__tests__/integration/models/pixel10ImageGenCpuPath.test.ts @@ -0,0 +1,46 @@ +import { Platform } from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import { hardwareService } from '../../../src/services/hardware'; + +const originalOS = Platform.OS; + +jest.mock('react-native-device-info', () => ({ + getModel: jest.fn(() => 'Pixel 10 Pro XL'), + getHardware: jest.fn(async () => 'tensor'), + getTotalMemory: jest.fn(async () => 12 * 1024 * 1024 * 1024), + getUsedMemory: jest.fn(async () => 4 * 1024 * 1024 * 1024), + getSystemName: jest.fn(() => 'Android'), + getSystemVersion: jest.fn(() => '17'), + isEmulator: jest.fn(async () => false), + getDeviceId: jest.fn(() => 'pixel10'), +})); + +describe('Pixel 10 image generation CPU path integration', () => { + beforeEach(() => { + Platform.OS = 'android'; + (hardwareService as any).cachedDeviceInfo = null; + (hardwareService as any).cachedSoCInfo = null; + (hardwareService as any).cachedImageRecommendation = null; + (hardwareService as any).cachedOpenCLCapability = null; + jest.clearAllMocks(); + }); + + afterEach(() => { + Platform.OS = originalOS; + }); + + it('detects Pixel 10 and disables OpenCL image acceleration', async () => { + expect(hardwareService.requiresCpuImageBackend()).toBe(true); + const openCl = await hardwareService.getOpenCLCapability(); + expect(openCl.supported).toBe(false); + expect(openCl.reason).toBe('pixel_10_cpu_only'); + }); + + it('recommends only MNN models with a Pixel 10-specific banner', async () => { + const rec = await hardwareService.getImageModelRecommendation(); + expect(rec.recommendedBackend).toBe('mnn'); + expect(rec.compatibleBackends).toEqual(['mnn']); + expect(rec.bannerText).toContain('Pixel 10'); + expect(DeviceInfo.getModel).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/unit/services/hardware.test.ts b/__tests__/unit/services/hardware.test.ts index 17e45ef49..41c869e47 100644 --- a/__tests__/unit/services/hardware.test.ts +++ b/__tests__/unit/services/hardware.test.ts @@ -18,6 +18,7 @@ describe('HardwareService', () => { (hardwareService as any).cachedDeviceInfo = null; (hardwareService as any).cachedSoCInfo = null; (hardwareService as any).cachedImageRecommendation = null; + (hardwareService as any).cachedOpenCLCapability = null; }); // ======================================================================== @@ -1013,7 +1014,7 @@ describe('HardwareService', () => { }); const rec = await hardwareService.getImageModelRecommendation(); expect(rec.recommendedBackend).toBe('mnn'); - expect(rec.bannerText).toContain('GPU'); + expect(rec.bannerText).toContain('MNN'); expect(rec.bannerText).toContain('888'); expect(rec.compatibleBackends).toEqual(['mnn']); }); @@ -1027,6 +1028,26 @@ describe('HardwareService', () => { }); const rec = await hardwareService.getImageModelRecommendation(); expect(rec.recommendedBackend).toBe('mnn'); + expect(rec.bannerText).toContain('MNN'); + expect(rec.bannerText).toContain('Tensor'); + }); + + it('recommends MNN with Pixel 10 CPU notice', async () => { + await setupDevice({ + totalGB: 12, + platform: 'android', + hardware: 'tensor', + model: 'Pixel 10 Pro XL', + }); + const rec = await hardwareService.getImageModelRecommendation(); + expect(rec.recommendedBackend).toBe('mnn'); + expect(rec.compatibleBackends).toEqual(['mnn']); + expect(rec.bannerText).toContain('Pixel 10'); + expect(hardwareService.requiresCpuImageBackend()).toBe(true); + expect(await hardwareService.getOpenCLCapability()).toEqual({ + supported: false, + reason: 'pixel_10_cpu_only', + }); }); }); @@ -1051,7 +1072,7 @@ describe('HardwareService', () => { const rec = await hardwareService.getImageModelRecommendation(); expect(rec.recommendedBackend).toBe('mnn'); - expect(rec.bannerText).toContain('GPU'); + expect(rec.bannerText).toContain('MNN'); expect(rec.compatibleBackends).toEqual(['mnn']); }); }); diff --git a/__tests__/unit/services/huggingFaceModelBrowser.test.ts b/__tests__/unit/services/huggingFaceModelBrowser.test.ts index bad2c8342..9b696cde4 100644 --- a/__tests__/unit/services/huggingFaceModelBrowser.test.ts +++ b/__tests__/unit/services/huggingFaceModelBrowser.test.ts @@ -55,7 +55,7 @@ describe('huggingFaceModelBrowser', () => { // parseFileName (tested indirectly via fetchAvailableModels) // ----------------------------------------------------------------------- describe('parseFileName (via fetchAvailableModels)', () => { - it('parses MNN backend zip as a GPU model', async () => { + it('parses MNN backend zip as an MNN model', async () => { mockFetchResponses( { ok: true, body: [treeEntry('AnythingV5.zip', 500, 'file', 2000)] }, { ok: true, body: [] }, @@ -67,7 +67,7 @@ describe('huggingFaceModelBrowser', () => { expect(models[0]).toMatchObject({ id: 'anythingv5_cpu', name: 'AnythingV5', - displayName: 'Anything V5 (GPU)', + displayName: 'Anything V5 (MNN)', backend: 'mnn', fileName: 'AnythingV5.zip', size: 2000, diff --git a/src/screens/DownloadManagerScreen/useDownloadManager.ts b/src/screens/DownloadManagerScreen/useDownloadManager.ts index 33d2ad832..cb0a15d01 100644 --- a/src/screens/DownloadManagerScreen/useDownloadManager.ts +++ b/src/screens/DownloadManagerScreen/useDownloadManager.ts @@ -58,7 +58,7 @@ function getActiveItemFileName( function getImageAuthor(backend?: string): string { if (backend === 'coreml') return 'Core ML'; if (backend === 'qnn') return 'NPU'; - if (backend === 'mnn') return 'GPU'; + if (backend === 'mnn') return 'MNN'; return 'Image Generation'; } diff --git a/src/screens/ModelsScreen/ImageFilterBar.tsx b/src/screens/ModelsScreen/ImageFilterBar.tsx index d5036b30c..3dd3d0d07 100644 --- a/src/screens/ModelsScreen/ImageFilterBar.tsx +++ b/src/screens/ModelsScreen/ImageFilterBar.tsx @@ -18,10 +18,12 @@ interface Props { hasActiveImageFilters: boolean; clearImageFilters: () => void; setUserChangedBackendFilter: (v: boolean) => void; + /** When false, hide the NPU filter chip (device has no QNN catalog entries). */ + npuAvailable?: boolean; } function getBackendLabel(filter: BackendFilter): string { - if (filter === 'mnn') return 'GPU'; + if (filter === 'mnn') return 'MNN'; if (filter === 'qnn') return 'NPU'; if (filter === 'coreml') return 'Core ML'; return 'Backend'; @@ -45,12 +47,13 @@ interface ExpandedSectionProps { setStyleFilter: (f: string) => void; setImageFilterExpanded: (d: ImageFilterDimension | ((prev: ImageFilterDimension) => ImageFilterDimension)) => void; setUserChangedBackendFilter: (v: boolean) => void; + backendOptions: { key: BackendFilter; label: string }[]; } const FilterExpandedSection: React.FC = ({ imageFilterExpanded, backendFilter, sdVersionFilter, styleFilter, setBackendFilter, setSdVersionFilter, setStyleFilter, - setImageFilterExpanded, setUserChangedBackendFilter, + setImageFilterExpanded, setUserChangedBackendFilter, backendOptions, }) => { const styles = useThemedStyles(createStyles); @@ -58,7 +61,7 @@ const FilterExpandedSection: React.FC = ({ return ( - {BACKEND_OPTIONS.map(option => ( + {backendOptions.map(option => ( = ({ imageFilterExpanded, setImageFilterExpanded, hasActiveImageFilters, clearImageFilters, setUserChangedBackendFilter, + npuAvailable = true, }) => { const styles = useThemedStyles(createStyles); + const backendOptions = npuAvailable + ? BACKEND_OPTIONS + : BACKEND_OPTIONS.filter(o => o.key !== 'qnn'); const backendLabel = getBackendLabel(backendFilter); const sdLabel = getSdLabel(sdVersionFilter); @@ -175,6 +182,7 @@ export const ImageFilterBar: React.FC = ({ setStyleFilter={setStyleFilter} setImageFilterExpanded={setImageFilterExpanded} setUserChangedBackendFilter={setUserChangedBackendFilter} + backendOptions={backendOptions} /> ); diff --git a/src/screens/ModelsScreen/ImageModelsTab.tsx b/src/screens/ModelsScreen/ImageModelsTab.tsx index 604435bd5..1d8f74247 100644 --- a/src/screens/ModelsScreen/ImageModelsTab.tsx +++ b/src/screens/ModelsScreen/ImageModelsTab.tsx @@ -28,7 +28,7 @@ type Props = Pick = ({ showRecHint, showRecommendedOnly, setShowRecHint, - imageRec, ramGB, imageRecommendation, + imageRec, ramGB, imageRecommendation, imageNpuAvailable, imageFiltersVisible, backendFilter, setBackendFilter, styleFilter, setStyleFilter, sdVersionFilter, setSdVersionFilter, imageFilterExpanded, setImageFilterExpanded, @@ -202,6 +203,7 @@ const ImageModelsScrollContent: React.FC = ({ hasActiveImageFilters={hasActiveImageFilters} clearImageFilters={clearImageFilters} setUserChangedBackendFilter={setUserChangedBackendFilter} + npuAvailable={imageNpuAvailable} /> )} @@ -262,7 +264,7 @@ export const ImageModelsTab: React.FC = ({ hasActiveImageFilters, showRecommendedOnly, setShowRecommendedOnly, showRecHint, setShowRecHint, - imageRec, ramGB, imageRecommendation, + imageRec, ramGB, imageRecommendation, imageNpuAvailable, handleDownloadImageModel, handleCancelImageDownload, loadHFModels, clearImageFilters, setUserChangedBackendFilter, isRecommendedModel, @@ -311,6 +313,7 @@ export const ImageModelsTab: React.FC = ({ imageRec={imageRec} ramGB={ramGB} imageRecommendation={imageRecommendation} + imageNpuAvailable={imageNpuAvailable} imageFiltersVisible={imageFiltersVisible} backendFilter={backendFilter} setBackendFilter={setBackendFilter} diff --git a/src/screens/ModelsScreen/constants.ts b/src/screens/ModelsScreen/constants.ts index e85353b3c..fc995b88b 100644 --- a/src/screens/ModelsScreen/constants.ts +++ b/src/screens/ModelsScreen/constants.ts @@ -58,7 +58,7 @@ export const SD_VERSION_OPTIONS = [ export const BACKEND_OPTIONS: { key: BackendFilter; label: string }[] = [ { key: 'all', label: 'All' }, - { key: 'mnn', label: 'GPU' }, + { key: 'mnn', label: 'MNN' }, { key: 'qnn', label: 'NPU' }, ]; diff --git a/src/screens/ModelsScreen/imageDownloadQnn.ts b/src/screens/ModelsScreen/imageDownloadQnn.ts index 9d4e98edc..f3c523f39 100644 --- a/src/screens/ModelsScreen/imageDownloadQnn.ts +++ b/src/screens/ModelsScreen/imageDownloadQnn.ts @@ -9,7 +9,7 @@ export function getQnnWarningMessage( if (!socInfo.hasNPU) { return 'NPU models require a Qualcomm Snapdragon processor. ' + 'Your device does not have a compatible NPU and this model will not work. ' + - 'Consider downloading a CPU model instead.'; + 'Consider downloading an MNN model instead.'; } if (!modelInfo.variant || !socInfo.qnnVariant) return null; diff --git a/src/screens/ModelsScreen/useImageModels.ts b/src/screens/ModelsScreen/useImageModels.ts index 4eff8e08a..a705da666 100644 --- a/src/screens/ModelsScreen/useImageModels.ts +++ b/src/screens/ModelsScreen/useImageModels.ts @@ -28,6 +28,7 @@ export function useImageModels(setAlertState: (s: AlertState) => void) { const [imageSearchQuery, setImageSearchQuery] = useState(''); const [imageFiltersVisible, setImageFiltersVisible] = useState(false); const [imageRec, setImageRec] = useState(null); + const [imageNpuAvailable, setImageNpuAvailable] = useState(false); const [userChangedBackendFilter, setUserChangedBackendFilter] = useState(false); const [showRecommendedOnly, setShowRecommendedOnly] = useState(true); const [showRecHint, setShowRecHint] = useState(true); @@ -66,6 +67,7 @@ export function useImageModels(setAlertState: (s: AlertState) => void) { }))); } else { const socInfo = await hardwareService.getSoCInfo(); + setImageNpuAvailable(socInfo.hasNPU); setAvailableHFModels(await fetchAvailableModels(forceRefresh, { skipQnn: !socInfo.hasNPU })); } } catch (error: any) { @@ -130,6 +132,11 @@ export function useImageModels(setAlertState: (s: AlertState) => void) { hardwareService.getImageModelRecommendation().then(rec => { if (cancelled) return; setImageRec(rec); + hardwareService.getSoCInfo().then(soc => { + if (cancelled) return; + setImageNpuAvailable(soc.hasNPU); + setBackendFilter(prev => (!soc.hasNPU && prev === 'qnn') ? 'mnn' : prev); + }); if (!userChangedBackendFilter && Platform.OS !== 'ios') { let filter: 'qnn' | 'mnn' | 'all'; if (rec.recommendedBackend === 'qnn') filter = 'qnn'; @@ -208,6 +215,7 @@ export function useImageModels(setAlertState: (s: AlertState) => void) { imageFiltersVisible, setImageFiltersVisible, imageRec, showRecommendedOnly, setShowRecommendedOnly, showRecHint, setShowRecHint, + imageNpuAvailable, downloadedImageModels, hasActiveImageFilters, filteredHFModels, imageRecommendation, loadHFModels, loadDownloadedImageModels, diff --git a/src/screens/ModelsScreen/useModelsScreen.ts b/src/screens/ModelsScreen/useModelsScreen.ts index eb8885563..0137d5a09 100644 --- a/src/screens/ModelsScreen/useModelsScreen.ts +++ b/src/screens/ModelsScreen/useModelsScreen.ts @@ -285,6 +285,7 @@ export function useModelsScreen() { imageFiltersVisible: image.imageFiltersVisible, setImageFiltersVisible: image.setImageFiltersVisible, imageRec: image.imageRec, + imageNpuAvailable: image.imageNpuAvailable, showRecommendedOnly: image.showRecommendedOnly, setShowRecommendedOnly: image.setShowRecommendedOnly, showRecHint: image.showRecHint, diff --git a/src/services/activeModelService/index.ts b/src/services/activeModelService/index.ts index 0c60c5d71..e711511dd 100644 --- a/src/services/activeModelService/index.ts +++ b/src/services/activeModelService/index.ts @@ -242,7 +242,7 @@ class ActiveModelService { return { canLoad: false, error: - 'NPU models require a Qualcomm Snapdragon processor. Your device does not have a compatible NPU. Please use a GPU model instead.', + 'NPU models require a Qualcomm Snapdragon processor. Your device does not have a compatible NPU. Please download an MNN model instead.', }; } } @@ -308,12 +308,13 @@ class ActiveModelService { } this.loadingState.image = true; this.notifyListeners(); + const forceCpuImage = hardwareService.requiresCpuImageBackend(); this.imageLoadPromise = doLoadImageModel({ model, modelId, imageThreads, needsThreadReload, - cpuOnly: false, + cpuOnly: forceCpuImage, preferGpu: hardwareService.preferGpuForImageGen(), store, timeoutMs, diff --git a/src/services/hardware.ts b/src/services/hardware.ts index 66edc80cc..75bbfd458 100644 --- a/src/services/hardware.ts +++ b/src/services/hardware.ts @@ -397,14 +397,23 @@ class HardwareService { rec = { recommendedBackend: 'mnn', bannerText: - 'GPU models recommended \u2014 your Snapdragon doesn\u2019t support NPU acceleration', + 'MNN models recommended \u2014 your Snapdragon does not support NPU acceleration', + compatibleBackends: ['mnn'], + }; + } else if (socInfo.vendor === 'tensor') { + rec = { + recommendedBackend: 'mnn', + bannerText: + this.requiresCpuImageBackend() + ? 'MNN models recommended for Pixel \u2014 image generation runs on CPU on Pixel 10 until GPU support lands' + : 'MNN models recommended for Pixel and Tensor devices', compatibleBackends: ['mnn'], }; } else { rec = { recommendedBackend: 'mnn', bannerText: - 'GPU models recommended \u2014 NPU requires Snapdragon 888+', + 'MNN models recommended \u2014 NPU acceleration requires Snapdragon 888+', compatibleBackends: ['mnn'], }; } @@ -436,6 +445,9 @@ class HardwareService { async getOpenCLCapability(): Promise<{ supported: boolean; reason?: string }> { if (this.cachedOpenCLCapability) return this.cachedOpenCLCapability; if (Platform.OS !== 'android') return { supported: false, reason: 'not_android' }; + if (this.requiresCpuImageBackend()) { + return (this.cachedOpenCLCapability = { supported: false, reason: 'pixel_10_cpu_only' }); + } try { const hardware = (await DeviceInfo.getHardware()).toLowerCase(); // Support Qualcomm Adreno (qcom) and ARM Mali GPUs. @@ -445,5 +457,14 @@ class HardwareService { return (this.cachedOpenCLCapability = { supported: true }); } catch { return (this.cachedOpenCLCapability = { supported: false, reason: 'detection_failed' }); } } + + /** + * Pixel 10 OpenCL/GPU image backends are broken on-device (same class of bug as + * LiteRT GPU on Pixel 10). Force the MNN CPU path for image load + generation. + */ + requiresCpuImageBackend(): boolean { + if (Platform.OS !== 'android') return false; + return DeviceInfo.getModel().toLowerCase().includes('pixel 10'); + } } export const hardwareService = new HardwareService(); diff --git a/src/services/huggingFaceModelBrowser.ts b/src/services/huggingFaceModelBrowser.ts index 9ff485a65..70733d883 100644 --- a/src/services/huggingFaceModelBrowser.ts +++ b/src/services/huggingFaceModelBrowser.ts @@ -59,11 +59,12 @@ function parseFileName(fileName: string, backend: 'mnn' | 'qnn'): Omit {