| import React, { useEffect, useMemo, useState } from 'react'; |
| import { StatusBar } from 'expo-status-bar'; |
| import { useKeepAwake } from 'expo-keep-awake'; |
| import { Asset } from 'expo-asset'; |
| import { ActivityIndicator, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; |
| import { NativeModules } from 'react-native'; |
| import * as DocumentPicker from 'expo-document-picker'; |
| import * as FileSystem from 'expo-file-system/legacy'; |
| import { createClipTokenizer } from './clipTokenizer'; |
|
|
| const BUILD_ID = '2026-02-12-v2-speed-exp-002'; |
| const QUICK_STEPS = 4; |
| const QUALITY_STEPS = 20; |
|
|
| const GUIDE_STEPS = [ |
| { key: 'tapLaunch', label: 'Tap to confirm launch' }, |
| { key: 'showExternalPath', label: 'Show app external pack path' }, |
| { key: 'unzipExternal', label: 'Unzip SD15 pack from external' }, |
| { key: 'loadTokenizer', label: 'Load tokenizer (bundled)' }, |
| { key: 'testNative', label: 'Test native ONNX (NNAPI -> CPU)' }, |
| { key: 'inspectUnet', label: 'Inspect pack UNet' }, |
| { key: 'inspectText', label: 'Inspect pack text_encoder' }, |
| { key: 'inspectVae', label: 'Inspect pack vae_decoder' }, |
| { key: 'generateQuick', label: `Generate 512x512 (quick ${QUICK_STEPS})` }, |
| { key: 'generateQuality', label: `Generate 512x512 (quality ${QUALITY_STEPS})` }, |
| { key: 'backendToggle', label: 'Backend: NNAPI / CPU' }, |
| { key: 'importOnnx', label: 'Import ONNX files (offline)' }, |
| { key: 'inspectImported', label: 'Inspect first imported model' }, |
| { key: 'importPackZip', label: 'Import SD15 pack (.zip)' }, |
| ] as const; |
|
|
| type GuideStepKey = (typeof GUIDE_STEPS)[number]['key']; |
|
|
| export default function App() { |
| useKeepAwake(); |
| const [taps, setTaps] = useState(0); |
| const [busy, setBusy] = useState(false); |
| const [provider, setProvider] = useState<string>('-'); |
| const [output, setOutput] = useState<string>('-'); |
| const [err, setErr] = useState<string>(''); |
| const [status, setStatus] = useState<string>('Idle'); |
| const [lastExportPath, setLastExportPath] = useState<string>('-'); |
| const [importedFiles, setImportedFiles] = useState<string[]>([]); |
| const [packDir, setPackDir] = useState<string>(''); |
| const [packFiles, setPackFiles] = useState<number>(0); |
| const [externalDir, setExternalDir] = useState<string>(''); |
| const [prompt, setPrompt] = useState<string>('A photo of a lion in the wild, ultra realistic'); |
| const [tokenizerReady, setTokenizerReady] = useState(false); |
| const [preferNnapi, setPreferNnapi] = useState(true); |
| const [guidedMode, setGuidedMode] = useState(true); |
| const [guideIndex, setGuideIndex] = useState(0); |
|
|
| const canRun = useMemo(() => true, []); |
| const guideDone = guideIndex >= GUIDE_STEPS.length; |
| const currentGuideStep = guideDone ? null : GUIDE_STEPS[guideIndex]; |
| const showGuideButton = (key: GuideStepKey) => !guidedMode || guideDone || currentGuideStep?.key === key; |
| const completeGuideStep = (key: GuideStepKey) => { |
| if (!guidedMode || guideDone) return; |
| if (currentGuideStep?.key !== key) return; |
| setGuideIndex((n) => n + 1); |
| }; |
|
|
| useEffect(() => { |
| let cancelled = false; |
| (async () => { |
| try { |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.getExternalFilesDirPath) return; |
| const p = await mod.getExternalFilesDirPath(); |
| if (cancelled) return; |
| const ext = String(p || ''); |
| if (!ext) return; |
| setExternalDir((prev) => prev || ext); |
|
|
| if (packDir) return; |
| if (!mod?.statPath) return; |
| const candidate = `${ext.replace(/\/+$/, '')}/sd15-onnx/sd15-onnx`; |
| const st = await mod.statPath(`file://${candidate}/unet.onnx`); |
| if (cancelled) return; |
| if (st?.exists) setPackDir((prev) => prev || candidate); |
| } catch { |
| |
| } |
| })(); |
| return () => { |
| cancelled = true; |
| }; |
| |
| }, []); |
|
|
| const packFileUri = (filename: string) => { |
| const base = packDir.startsWith('file://') ? packDir.slice('file://'.length) : packDir; |
| const baseNoSlash = base.replace(/\/+$/, ''); |
| return `file://${baseNoSlash}/${filename}`; |
| }; |
|
|
| const tokenizer = useMemo(() => { |
| if (!tokenizerReady) return null; |
| return (globalThis as any).__sp_clip_tokenizer__ ?? null; |
| }, [tokenizerReady]); |
|
|
| const yieldToUi = async () => new Promise<void>((r) => setTimeout(r, 0)); |
|
|
| const runGenerate = async (steps: number, forceCpu: boolean = false, lowMemoryMode: boolean = true) => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| try { |
| if (!packDir) throw new Error('Pack not set. Tap "Unzip SD15 pack from external" (once) if needed.'); |
| if (!externalDir) throw new Error('External dir is not set. Tap "Show app external pack path" first.'); |
| if (!tokenizer) throw new Error('Tokenizer not loaded. Tap "Load tokenizer (bundled)" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.generateSd15FromIds) throw new Error('Native module OrtNative.generateSd15FromIds is not available (rebuild required).'); |
|
|
| setStatus('Tokenizing...'); |
| await yieldToUi(); |
| const condIds = tokenizer.encode(prompt, 77); |
| const uncondIds = tokenizer.encode('', 77); |
|
|
| const outPath = `file://${externalDir.replace(/\/+$/, '')}/sd_out_${Date.now()}.png`; |
| setStatus(`Generating... (${steps} steps)`); |
| await yieldToUi(); |
| const useNnapi = forceCpu ? false : preferNnapi; |
| const runOnce = async (nnapi: boolean, lowMem: boolean) => |
| mod.generateSd15FromIds({ |
| packDir, |
| condIds, |
| uncondIds, |
| steps, |
| guidance: 7.5, |
| seed: -1, |
| outPath, |
| preferNnapi: nnapi, |
| lowMemoryMode: lowMem, |
| }); |
|
|
| let res: any; |
| try { |
| res = await runOnce(useNnapi, lowMemoryMode); |
| } catch (firstErr: any) { |
| |
| if (useNnapi) { |
| setStatus('NNAPI failed, retrying CPU...'); |
| await yieldToUi(); |
| res = await runOnce(false, true); |
| } else { |
| throw firstErr; |
| } |
| } |
| try { |
| if (mod?.exportImageToGallery && res?.path) { |
| const fileName = String(res.path).split('/').pop() || `sd_out_${Date.now()}.png`; |
| const exported = await mod.exportImageToGallery(String(res.path), fileName); |
| setLastExportPath(String(exported || `Pictures/Staticplay/${fileName}`)); |
| } |
| } catch (exportErr: any) { |
| |
| const msg = String(exportErr?.message ?? exportErr ?? '').trim(); |
| if (msg) { |
| setErr((prev) => (prev ? `${prev}\nExport: ${msg}` : `Export: ${msg}`)); |
| } |
| } |
| setProvider(String(res?.provider ?? 'unknown')); |
| setOutput(JSON.stringify(res)); |
| setStatus('Done'); |
| } catch (e: any) { |
| const msg = String(e?.message ?? e ?? '').trim(); |
| setErr(msg || 'Generation failed (unknown error)'); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| } |
| }; |
|
|
| return ( |
| <SafeAreaView style={styles.safe}> |
| <ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled"> |
| <Text style={styles.title}>Staticplay</Text> |
| <Text style={styles.sub}>Offline-first SD1 on-device (Android) - {BUILD_ID}</Text> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Guided mode</Text> |
| <Text style={styles.value}> |
| {guidedMode |
| ? guideDone |
| ? 'Done. All guided steps completed.' |
| : `Step ${guideIndex + 1}/${GUIDE_STEPS.length}: ${currentGuideStep?.label}` |
| : 'Off. Showing all buttons.'} |
| </Text> |
| <View style={styles.row}> |
| <Pressable style={[styles.smallBtn, styles.smallBtnPrimary]} onPress={() => setGuidedMode((v) => !v)}> |
| <Text style={styles.smallBtnText}>{guidedMode ? 'Show all buttons' : 'Enable guided mode'}</Text> |
| </Pressable> |
| <Pressable |
| style={[styles.smallBtn, styles.smallBtnGhost]} |
| onPress={() => { |
| setGuideIndex(0); |
| setGuidedMode(true); |
| }} |
| > |
| <Text style={styles.smallBtnText}>Restart guide</Text> |
| </Pressable> |
| </View> |
| </View> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Prompt</Text> |
| <TextInput |
| value={prompt} |
| onChangeText={setPrompt} |
| placeholder="Describe an image..." |
| placeholderTextColor="#5b6f92" |
| style={styles.input} |
| multiline |
| /> |
| <Text style={[styles.value, { marginTop: 8, color: '#8aa0c4' }]}> |
| 512x512 - quick {QUICK_STEPS} / quality {QUALITY_STEPS} - guidance 7.5 |
| </Text> |
| </View> |
| |
| {showGuideButton('tapLaunch') ? ( |
| <Pressable |
| style={[styles.btn, !canRun ? styles.btnDisabled : null]} |
| disabled={!canRun} |
| onPress={() => { |
| setTaps((n) => n + 1); |
| completeGuideStep('tapLaunch'); |
| }} |
| > |
| <Text style={styles.btnText}>Tap to confirm launch ({taps})</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('backendToggle') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={() => { |
| setPreferNnapi((v) => !v); |
| completeGuideStep('backendToggle'); |
| }} |
| > |
| <Text style={styles.btnText}>{preferNnapi ? 'Backend: NNAPI (experimental)' : 'Backend: CPU (safe)'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('loadTokenizer') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| let loaded = false; |
| try { |
| setStatus('Loading tokenizer...'); |
| const vocabAsset = Asset.fromModule(require('./assets/tokenizer/vocab.json.txt')); |
| const mergesAsset = Asset.fromModule(require('./assets/tokenizer/merges.txt')); |
| await vocabAsset.downloadAsync(); |
| await mergesAsset.downloadAsync(); |
| const vocabUri = vocabAsset.localUri || vocabAsset.uri; |
| const mergesUri = mergesAsset.localUri || mergesAsset.uri; |
| if (!vocabUri) throw new Error('Tokenizer vocab asset missing local URI.'); |
| if (!mergesUri) throw new Error('Tokenizer merges asset missing local URI.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| const readText = mod?.readTextFile |
| ? async (uri: string) => String(await mod.readTextFile(uri)) |
| : async (uri: string) => String(await FileSystem.readAsStringAsync(uri)); |
| let vocabJson = ''; |
| let mergesTxt = ''; |
| try { |
| vocabJson = await readText(vocabUri); |
| mergesTxt = await readText(mergesUri); |
| } catch { |
| // Fallback: read tokenizer from app external storage if bundled read fails. |
| if (!externalDir) throw new Error('Tokenizer read failed and external dir is not set.'); |
| const extBase = externalDir.replace(/\/+$/, ''); |
| vocabJson = await readText(`file://${extBase}/tokenizer/vocab.json.txt`); |
| mergesTxt = await readText(`file://${extBase}/tokenizer/merges.txt`); |
| } |
| (globalThis as any).__sp_clip_tokenizer__ = createClipTokenizer({ vocabJson, mergesTxt }); |
| setTokenizerReady(true); |
| setStatus('OK'); |
| loaded = true; |
| } catch (e: any) { |
| const msg = String(e?.message ?? e ?? '').trim(); |
| setErr(msg || 'Tokenizer load failed (unknown error)'); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| if (loaded) completeGuideStep('loadTokenizer'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : tokenizerReady ? 'Tokenizer loaded' : 'Load tokenizer (bundled)'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('generateQuick') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| // v2 quick path: NNAPI-first and less memory-constrained for speed. |
| await runGenerate(QUICK_STEPS, false, false); |
| completeGuideStep('generateQuick'); |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : `Generate 512x512 (quick ${QUICK_STEPS})`}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('generateQuality') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| // v2 quality path: keep stable settings. |
| await runGenerate(QUALITY_STEPS, true, true); |
| completeGuideStep('generateQuality'); |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : `Generate 512x512 (quality ${QUALITY_STEPS})`}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('testNative') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| setStatus('Running identity ONNX...'); |
| try { |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.runIdentity) throw new Error('Native module OrtNative is not available (rebuild required).'); |
| const res = await mod.runIdentity([1, 2, 3]); |
| setProvider(String(res?.provider ?? 'unknown')); |
| const outArr = res?.output ?? []; |
| setOutput(JSON.stringify(outArr)); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('testNative'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Running...' : 'Test native ONNX (NNAPI -> CPU)'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('importOnnx') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setStatus('Picking ONNX files...'); |
| try { |
| const res = await DocumentPicker.getDocumentAsync({ |
| multiple: true, |
| type: ['application/octet-stream', '*/*'], |
| copyToCacheDirectory: true, |
| }); |
| if (res.canceled) return; |
| |
| const destDir = `${FileSystem.documentDirectory}models/sd1/`; |
| await FileSystem.makeDirectoryAsync(destDir, { intermediates: true }); |
| |
| const saved: string[] = []; |
| for (const asset of res.assets) { |
| const name = (asset.name || 'model.onnx').replace(/[^\w.\-]+/g, '_'); |
| const to = `${destDir}${name}`; |
| await FileSystem.copyAsync({ from: asset.uri, to }); |
| saved.push(to); |
| } |
| setImportedFiles((prev) => Array.from(new Set([...saved, ...prev]))); |
| setStatus(`Imported ${saved.length} file(s)`); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('importOnnx'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : 'Import ONNX files (offline)'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('inspectImported') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| setStatus('Inspecting...'); |
| try { |
| if (importedFiles.length === 0) throw new Error('No imported ONNX files yet. Tap "Import ONNX files (offline)" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.inspectModel) throw new Error('Native module OrtNative.inspectModel is not available (rebuild required).'); |
| const target = importedFiles[0]; |
| const info = await mod.inspectModel(target); |
| setProvider(String(info?.provider ?? 'unknown')); |
| setOutput(JSON.stringify({ file: target.split('/').pop(), inputs: info?.inputs, outputs: info?.outputs })); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('inspectImported'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>Inspect first imported model</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('importPackZip') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setStatus('Picking zip...'); |
| try { |
| const res = await DocumentPicker.getDocumentAsync({ |
| multiple: false, |
| type: ['application/zip', 'application/octet-stream', '*/*'], |
| copyToCacheDirectory: false, |
| }); |
| if (res.canceled) return; |
| const asset = res.assets[0]; |
| const zipUri = asset.uri; |
| |
| const destDir = `${FileSystem.documentDirectory}packs/sd15-onnx/`; |
| await FileSystem.makeDirectoryAsync(destDir, { intermediates: true }); |
| |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.unzip) throw new Error('Native module OrtNative.unzip is not available (rebuild required).'); |
| if (mod?.statPath) { |
| const st = await mod.statPath(zipUri); |
| if (st && st.exists === false) throw new Error(`Zip not found: ${String(st.path || zipUri)}`); |
| } |
| setStatus('Unzipping... (this can take minutes)'); |
| const out = await mod.unzip(zipUri, destDir); |
| { |
| const base = String(out?.destPath || destDir); |
| const normalized = base.startsWith('file://') ? base.slice('file://'.length) : base; |
| setPackDir(`${normalized.replace(/\/+$/, '')}/sd15-onnx`); |
| } |
| setPackFiles(Number(out?.files || 0)); |
| setStatus(`Unzipped ${Number(out?.files || 0)} file(s)`); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('importPackZip'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : 'Import SD15 pack (.zip)'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('inspectUnet') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| setStatus('Inspecting UNet...'); |
| try { |
| if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.inspectModel) throw new Error('Native module OrtNative.inspectModel is not available (rebuild required).'); |
| const info = await mod.inspectModel(packFileUri('unet.onnx')); |
| setProvider(String(info?.provider ?? 'unknown')); |
| setOutput(JSON.stringify({ file: 'unet.onnx', inputs: info?.inputs, outputs: info?.outputs })); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('inspectUnet'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>Inspect pack UNet</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('inspectText') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| setStatus('Inspecting text_encoder (CPU)...'); |
| try { |
| if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.inspectModelCpu) throw new Error('Native module OrtNative.inspectModelCpu is not available (rebuild required).'); |
| const info = await mod.inspectModelCpu(packFileUri('text_encoder.onnx')); |
| setProvider(String(info?.provider ?? 'unknown')); |
| setOutput(JSON.stringify({ file: 'text_encoder.onnx', inputs: info?.inputs, outputs: info?.outputs })); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('inspectText'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>Inspect pack text_encoder</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('inspectVae') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| setProvider('-'); |
| setOutput('-'); |
| setStatus('Inspecting vae_decoder (CPU)...'); |
| try { |
| if (!packDir) throw new Error('Pack not set. Tap "Import SD15 pack (.zip)" or "Unzip SD15 pack from external" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.inspectModelCpu) throw new Error('Native module OrtNative.inspectModelCpu is not available (rebuild required).'); |
| const info = await mod.inspectModelCpu(packFileUri('vae_decoder.onnx')); |
| setProvider(String(info?.provider ?? 'unknown')); |
| setOutput(JSON.stringify({ file: 'vae_decoder.onnx', inputs: info?.inputs, outputs: info?.outputs })); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('inspectVae'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>Inspect pack vae_decoder</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('showExternalPath') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| try { |
| setStatus('Getting external path...'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.getExternalFilesDirPath) throw new Error('Native module OrtNative.getExternalFilesDirPath is not available (rebuild required).'); |
| const p = await mod.getExternalFilesDirPath(); |
| setExternalDir(String(p || '')); |
| setStatus('OK'); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('showExternalPath'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : 'Show app external pack path'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {showGuideButton('unzipExternal') ? ( |
| <Pressable |
| style={[styles.btn, busy ? styles.btnDisabled : null]} |
| disabled={busy} |
| onPress={async () => { |
| setBusy(true); |
| setErr(''); |
| try { |
| if (!externalDir) throw new Error('External dir is not set. Tap "Show app external pack path" first.'); |
| const mod = (NativeModules as any)?.OrtNative; |
| if (!mod?.unzip) throw new Error('Native module OrtNative.unzip is not available (rebuild required).'); |
| const extBase = externalDir.replace(/\/+$/, ''); |
| const zipCandidates = [ |
| `file://${extBase}/sd15-onnx-pack.zip`, |
| 'file:///storage/emulated/0/Android/data/com.anonymous.staticplaysd1mobile/files/sd15-onnx-pack.zip', |
| 'file:///storage/emulated/0/Download/sd15-onnx-pack.zip', |
| ]; |
| let zipUri = zipCandidates[0]; |
| if (mod?.statPath) { |
| let found = ''; |
| for (const candidate of zipCandidates) { |
| const st = await mod.statPath(candidate); |
| if (st?.exists) { |
| found = candidate; |
| break; |
| } |
| } |
| if (!found) throw new Error(`Zip not found in any expected location: ${zipCandidates.join(' | ')}`); |
| zipUri = found; |
| } |
| const destDir = `file://${externalDir.replace(/\/+$/, '')}/sd15-onnx/`; |
| setStatus('Unzipping... (this can take minutes)'); |
| const out = await mod.unzip(zipUri, destDir); |
| { |
| const base = String(out?.destPath || destDir); |
| const normalized = base.startsWith('file://') ? base.slice('file://'.length) : base; |
| setPackDir(`${normalized.replace(/\/+$/, '')}/sd15-onnx`); |
| } |
| setPackFiles(Number(out?.files || 0)); |
| setStatus(`Unzipped ${Number(out?.files || 0)} file(s)`); |
| } catch (e: any) { |
| setErr(e?.message || String(e)); |
| setStatus('Error'); |
| } finally { |
| setBusy(false); |
| completeGuideStep('unzipExternal'); |
| } |
| }} |
| > |
| <Text style={styles.btnText}>{busy ? 'Working...' : 'Unzip SD15 pack from external'}</Text> |
| </Pressable> |
| ) : null} |
| |
| {busy ? <ActivityIndicator style={{ marginTop: 6 }} /> : null} |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Status</Text> |
| <Text style={styles.value}>{status}</Text> |
| <Text style={[styles.label, { marginTop: 10 }]}>Next</Text> |
| <Text style={styles.value}>See docs/SD1_ON_DEVICE_PLAN.md</Text> |
| </View> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Native provider</Text> |
| <Text style={styles.value}>{provider}</Text> |
| <Text style={[styles.label, { marginTop: 10 }]}>Native output</Text> |
| <Text style={styles.value}>{output}</Text> |
| {err ? ( |
| <> |
| <Text style={[styles.label, { marginTop: 10, color: '#fbbf24' }]}>Error</Text> |
| <Text selectable style={[styles.value, { color: '#fbbf24' }]}> |
| {err} |
| </Text> |
| </> |
| ) : null} |
| </View> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Last export path</Text> |
| <Text style={styles.value}>{lastExportPath || '-'}</Text> |
| </View> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Imported files</Text> |
| <Text style={styles.value}>{importedFiles.length ? importedFiles.map((p) => p.split('/').pop()).join(', ') : '-'}</Text> |
| </View> |
| |
| <View style={styles.card}> |
| <Text style={styles.label}>Pack dir</Text> |
| <Text style={styles.value}>{packDir || '-'}</Text> |
| <Text style={[styles.label, { marginTop: 10 }]}>Pack files</Text> |
| <Text style={styles.value}>{packDir ? String(packFiles) : '-'}</Text> |
| <Text style={[styles.label, { marginTop: 10 }]}>External dir</Text> |
| <Text style={styles.value}>{externalDir || '-'}</Text> |
| </View> |
| </ScrollView> |
| <StatusBar style="auto" /> |
| </SafeAreaView> |
| ); |
| } |
|
|
| const styles = StyleSheet.create({ |
| safe: { flex: 1, backgroundColor: '#0b0f19' }, |
| container: { padding: 18, gap: 12 }, |
| title: { color: '#eaf0ff', fontSize: 22, fontWeight: '900' }, |
| sub: { color: '#8aa0c4', fontSize: 12 }, |
| btn: { |
| backgroundColor: '#2563eb', |
| paddingVertical: 12, |
| paddingHorizontal: 14, |
| borderRadius: 12, |
| alignItems: 'center', |
| marginTop: 6, |
| }, |
| btnDisabled: { opacity: 0.5 }, |
| btnText: { color: 'white', fontWeight: '800' }, |
| card: { |
| backgroundColor: 'rgba(0,0,0,0.25)', |
| borderWidth: 1, |
| borderColor: 'rgba(255,255,255,0.09)', |
| borderRadius: 12, |
| padding: 14, |
| marginTop: 8, |
| }, |
| label: { color: '#9db2d6', fontSize: 12 }, |
| value: { color: '#eaf0ff', marginTop: 4 }, |
| input: { |
| color: '#eaf0ff', |
| marginTop: 8, |
| padding: 10, |
| borderRadius: 10, |
| backgroundColor: 'rgba(255,255,255,0.05)', |
| borderWidth: 1, |
| borderColor: 'rgba(255,255,255,0.10)', |
| minHeight: 80, |
| textAlignVertical: 'top', |
| }, |
| row: { |
| flexDirection: 'row', |
| gap: 8, |
| marginTop: 10, |
| }, |
| smallBtn: { |
| borderRadius: 10, |
| paddingVertical: 8, |
| paddingHorizontal: 10, |
| }, |
| smallBtnPrimary: { |
| backgroundColor: '#1d4ed8', |
| }, |
| smallBtnGhost: { |
| backgroundColor: '#1f2937', |
| borderWidth: 1, |
| borderColor: '#334155', |
| }, |
| smallBtnText: { |
| color: '#eaf0ff', |
| fontWeight: '700', |
| fontSize: 12, |
| }, |
| }); |
|
|