diff --git a/.gitignore b/.gitignore index adc40d6..c57fc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ dist/ *.log .metro-health-check* +# Python +__pycache__/ +*.pyc + # Android android/.gradle/ android/build/ diff --git a/App.tsx b/App.tsx index 52dd540..6cb347a 100644 --- a/App.tsx +++ b/App.tsx @@ -1,229 +1,155 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - ActivityIndicator, - FlatList, - Image, - Pressable, - SafeAreaView, - StyleSheet, - Text, - ToastAndroid, - View, -} from 'react-native'; -import { FileUtils, PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; - -type SortKey = 'date_desc' | 'date_asc' | 'name'; -type ImageItem = { name: string; path: string }; - -const IMAGE_EXTS = ['.png']; -const IMAGES_DIR = '/storage/emulated/0/Document/Images'; - -const SORT_OPTIONS: { key: SortKey; label: string }[] = [ - { key: 'date_desc', label: 'Newest' }, - { key: 'date_asc', label: 'Oldest' }, - { key: 'name', label: 'Name' }, -]; - -function basename(path: string): string { - const i = path.lastIndexOf('/'); - return i >= 0 ? path.slice(i + 1) : path; +import React, { useCallback, useEffect, useState } from 'react'; +import { PluginManager } from 'sn-plugin-lib'; +import { checkPendingButton } from './pendingButton'; +import { ImageProcessor } from './src/imageProcessor'; +import { FileLogger } from './src/util/FileLogger'; +import { BrowserScreen } from './src/screens/Browser'; +import { CaptureScreen } from './src/screens/Capture'; +import { DropInbox } from './src/screens/DropInbox'; +import { PreviewScreen } from './src/screens/Preview'; +import { LayerStitch } from './src/screens/LayerStitch'; +import { RecognizeLasso } from './src/screens/RecognizeLasso'; +import { RefreshScreen } from './src/screens/Refresh'; +import { SettingsScreen } from './src/screens/Settings'; +import { SourcePicker } from './src/screens/SourcePicker'; +import { StitchEditor, makeSession, StitchSession } from './src/screens/StitchEditor'; +import { baseUrl, loadStreamConfig } from './src/storage'; +import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; + +// Must match index.js. BUTTON_LASSO_SEND (4) is showType:0 — runs +// headlessly from index.js's button handler — so no route entry here. +const BUTTON_REFRESH = 2; +const BUTTON_DROP = 3; +const BUTTON_LASSO_RECOGNIZE = 5; +const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso-toolbar entry +const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar entry (always-visible duplicate) + +function initialScreenFromPendingButton(): Screen { + const id = checkPendingButton(); + let chosen: Screen = 'browser'; + if (id === BUTTON_REFRESH) chosen = 'refresh'; + else if (id === BUTTON_DROP) chosen = 'dropinbox'; + else if (id === BUTTON_LASSO_RECOGNIZE) chosen = 'recognizelasso'; + else if (id === BUTTON_LASSO_STITCH_LAYERS) chosen = 'layerstitch'; + else if (id === BUTTON_STITCH_LAYERS_SIDE) chosen = 'layerstitch'; + FileLogger.raw('[embedimage] App initialScreen', { pendingId: id, chosen }); + return chosen; } -function isImage(name: string): boolean { - const lower = name.toLowerCase(); - return IMAGE_EXTS.some((ext) => lower.endsWith(ext)); -} +export default function App(): React.JSX.Element { + const [screen, setScreen] = useState(() => initialScreenFromPendingButton()); + const [selectedEntry, setSelectedEntry] = useState(null); + const [streamConfig, setStreamConfig] = useState(DEFAULT_STREAM_CONFIG); + const [stitchSession, setStitchSession] = useState(null); -function sortItems(items: ImageItem[], sort: SortKey): ImageItem[] { - const copy = items.slice(); - const byName = (a: ImageItem, b: ImageItem) => - a.name.localeCompare(b.name, undefined, { numeric: true }); - if (sort === 'name') copy.sort(byName); - else if (sort === 'date_asc') copy.sort(byName); - else copy.sort((a, b) => byName(b, a)); - return copy; -} + useEffect(() => { + FileLogger.raw('[embedimage] App mounted'); + ImageProcessor?.cleanupCache?.().catch(() => {}); + (async () => { + const cfg = await loadStreamConfig(); + setStreamConfig(cfg); + })(); + + const sub = PluginManager.registerButtonListener({ + onButtonPress: (msg: any) => { + FileLogger.raw('[embedimage] App onButtonPress', { id: msg?.id }); + if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); + else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); + else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); + else if (msg?.id === BUTTON_LASSO_STITCH_LAYERS) setScreen('layerstitch'); + else if (msg?.id === BUTTON_STITCH_LAYERS_SIDE) setScreen('layerstitch'); + }, + }); + return () => { + sub?.remove?.(); + }; + }, []); -export default function App(): React.JSX.Element { - const [items, setItems] = useState([]); - const [sort, setSort] = useState('date_desc'); - const [status, setStatus] = useState('starting…'); - const [busy, setBusy] = useState(false); - - const load = useCallback(async () => { - setStatus(`checking ${IMAGES_DIR}`); - try { - let exists = false; - try { - exists = await FileUtils.exists(IMAGES_DIR); - } catch (e: any) { - setStatus(`exists() threw: ${e?.message ?? e}`); - return; - } - if (!exists) { - setStatus('directory missing — creating'); - try { - await FileUtils.makeDir(IMAGES_DIR); - } catch (e: any) { - setStatus(`makeDir() threw: ${e?.message ?? e}`); - return; - } - } - - setStatus('listing files…'); - let entries: any = null; - try { - entries = await FileUtils.listFiles(IMAGES_DIR); - } catch (e: any) { - setStatus(`listFiles() threw: ${e?.message ?? e}`); - return; - } - - const list: any[] = Array.isArray(entries) ? entries : []; - const imgs: ImageItem[] = []; - for (const entry of list) { - const path = typeof entry === 'string' ? entry : entry?.path; - const type = typeof entry === 'string' ? 1 : entry?.type; - if (!path || type === 0) continue; - const name = basename(path); - if (!isImage(name)) continue; - imgs.push({ name, path }); - } - setItems(imgs); - setStatus(`found ${imgs.length} image${imgs.length === 1 ? '' : 's'} (raw entries: ${list.length})`); - } catch (err: any) { - setStatus(`unexpected: ${err?.message ?? err}`); - } + const openPreview = useCallback((entry: Entry) => { + setSelectedEntry(entry); + setScreen('preview'); }, []); - useEffect(() => { - load(); - }, [load]); - - const sorted = useMemo(() => sortItems(items, sort), [items, sort]); - - const onPick = useCallback(async (item: ImageItem) => { - if (busy) return; - setBusy(true); - setStatus(`embedding ${item.name}…`); - try { - const res: any = await PluginNoteAPI.insertImage(item.path); - if (!res || res.success === false) { - const msg = res?.error?.message ?? 'insertImage failed'; - setStatus(`insert failed: ${msg}`); - return; - } - try { await PluginNoteAPI.saveCurrentNote(); } catch {} - try { - ToastAndroid.showWithGravity(`Embedded ${item.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); - } catch {} - PluginManager.closePluginView().catch(() => {}); - } catch (err: any) { - setStatus(`insert threw: ${err?.message ?? err}`); - } finally { - setBusy(false); - } - }, [busy]); + const goBrowser = useCallback(() => { + setSelectedEntry(null); + setScreen('browser'); + }, []); + + if (screen === 'refresh') { + return ; + } + + if (screen === 'dropinbox') { + return ; + } + + if (screen === 'recognizelasso') { + return ; + } + + if (screen === 'layerstitch') { + return ; + } + + if (screen === 'stitch' && stitchSession) { + return ( + { setStitchSession(null); goBrowser(); }} + onInserted={() => { + setStitchSession(null); + PluginManager.closePluginView().catch(() => {}); + }} + /> + ); + } + + if (screen === 'preview' && selectedEntry) { + return ; + } + + if (screen === 'settings') { + return ( + setStreamConfig(cfg)} + /> + ); + } + + if (screen === 'capture') { + return ( + setScreen('settings')} + onOpenSourcePicker={() => setScreen('sourcepicker')} + /> + ); + } + + if (screen === 'sourcepicker') { + return ( + setScreen('capture')} + onChanged={() => setScreen('capture')} + /> + ); + } return ( - - - Embed PNG - PluginManager.closePluginView().catch(() => {})}> - Close - - - - {status} - - - Sort: - {SORT_OPTIONS.map((opt) => { - const active = opt.key === sort; - return ( - setSort(opt.key)} - style={[styles.chip, active && styles.chipActive]} - > - {opt.label} - - ); - })} - - - Refresh - - - - {sorted.length === 0 ? ( - - No PNG files yet. - {IMAGES_DIR} - - ) : ( - it.path} - numColumns={3} - contentContainerStyle={styles.grid} - renderItem={({ item }) => ( - onPick(item)} disabled={busy}> - - {item.name} - - )} - /> - )} - - {busy ? ( - - - - ) : null} - + setScreen('settings')} + onOpenCapture={() => setScreen('capture')} + onClose={() => {}} + busy={false} + onStitchReady={(session) => { + setStitchSession(session); + setScreen('stitch'); + }} + /> ); } - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', - }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', - }, - sortBar: { - flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 12, paddingVertical: 8, gap: 8, - borderBottomWidth: 1, borderBottomColor: '#ccc', - }, - sortLabel: { fontSize: 14, color: '#000', marginRight: 4 }, - chip: { paddingHorizontal: 12, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, - chipActive: { backgroundColor: '#000' }, - chipTxt: { fontSize: 14, color: '#000' }, - chipTxtActive: { color: '#fff' }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - grid: { padding: 8 }, - tile: { flex: 1 / 3, padding: 6, alignItems: 'center' }, - thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, - tileName: { marginTop: 4, fontSize: 12, color: '#000', textAlign: 'center' }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - empty: { fontSize: 16, color: '#000', marginBottom: 4 }, - emptySub: { fontSize: 13, color: '#444', textAlign: 'center' }, - overlay: { - position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', - alignItems: 'center', justifyContent: 'center', - }, -}); diff --git a/PluginConfig.json b/PluginConfig.json index d418d9b..7a9e80d 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -1,9 +1,9 @@ { - "name": "Embed PNG", - "desc": "Browse PNG files in Document/Images and embed them into the current note.", + "name": "Embed Image", + "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.2.0", - "versionCode": "7", + "versionName": "0.9.7", + "versionCode": "34", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/README.md b/README.md index e789aa7..65435cf 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,32 @@ -# Embed PNG — Supernote Plugin +# Embed Image — Supernote Plugin > Vibe-coded with Claude Code. Read the diffs before trusting it on a device you care about. -Adds an **Embed PNG** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a browser of PNG files in `Document/Images` (auto-created if absent) with thumbnails, filenames, and a sort selector (Newest — default, Oldest, Name). Selecting a PNG inserts it into the current note on the current layer; reposition or resize it afterward with the lasso tool. +Adds an **Embed Image** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a file browser starting at `Document/Images` (auto-created if absent). Tap folders to navigate, **Up** / **Home** to jump around, or **Browse…** to open the device's native file picker — which includes mounted **WebDAV** servers and the **SD card**. Tap a thumbnail to open the preview, adjust, then **Insert** into the current note on the current layer (reposition or resize afterward with the lasso tool). -**PNG only.** The Supernote host's image-insert API is built around PNG (the SDK parameter is named `pngPath`). JPEG/WebP files in the directory are ignored. Convert other formats to PNG before dropping them in. +**Supported formats.** PNG, JPEG, BMP, GIF (first frame), WEBP. PNGs with no adjustments are inserted as-is. Everything else is decoded and re-encoded to PNG in the app cache before being handed to `PluginNoteAPI.insertImage`, which is PNG-only. + +**Preview adjustments.** + +- **Fade to white** — 0 % = original, 100 % = pure white. For tracing references on e-ink: pick a high fade so your strokes stand out against a ghosted reference. +- **Brightness** — −100 .. +100, linear offset. +- **Contrast** — −100 .. +100, scaled around the midpoint. +- **Gamma** — 0.5 .. 2.0, per-channel LUT. + +The preview re-bakes (downsampled to 800 px) ~350 ms after the last slider change to keep e-ink refresh manageable. The full-resolution bake happens on **Insert**. ## Layout - `PluginConfig.json` — plugin manifest (id, name, icon, version) - `index.js` — registers the sidebar button via `PluginManager.registerButton(1, ['NOTE'], …)` -- `App.tsx` — full-screen picker UI (thumbnail grid + sort) +- `App.tsx` — top-level router. Hosts `streamConfig` and switches between Browser, Preview, Settings, and Capture screens. +- `src/screens/*.tsx` — one file per screen. `Browser` is the folder navigator + system-picker handoff. `Preview` is the static-image preview. `Settings` configures the Mac capture server. `Capture` is the live-stream screen with start/pause, interval steppers, the adjustment panel, a rolling log, and Insert / Replace-in-place. +- `src/AdjustmentPanel.tsx` — tabbed Fade / Brightness / Contrast / Gamma with one slider visible at a time plus preset chips. +- `src/RangeSlider.tsx` — custom PanResponder-based slider (no extra deps). +- `src/imageProcessor.ts`, `src/storage.ts`, `src/embedTracker.ts` — native-module bindings, persistent config helpers, and the element-tracking layer that powers Replace-in-place via `PluginFileAPI.deleteElements` + `insertElements`. - `assets/icon.png` — sidebar button icon (pre-rendered from the source SVG) -- `android/` — RN Android shell. An empty `StubPackage` is registered to force `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) +- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (decode → B/C/gamma LUT → white-overlay → PNG bake, with optional downsample for previews, plus `downloadAndProcess` for live streaming and SharedPreferences-backed `getConfigValue`/`setConfigValue`) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device). `AndroidManifest.xml` declares `android:usesCleartextTraffic="true"` so the plugin can fetch over HTTP on the LAN. +- `macapp/` — the Mac-side Python capture server + Tkinter GUI. See `macapp/README.md`. - `buildPlugin.sh` — bundles JS + native APK + manifest into `build/outputs/embedimage.snplg` ## Build & install diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e189252..89ac1d1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" + android:usesCleartextTraffic="true" android:supportsRtl="true"> { + val u = URL(url) + require(u.protocol.equals("http", ignoreCase = true)) { "only http:// supported" } + val host = u.host + val port = if (u.port == -1) 80 else u.port + val path = (u.path.ifEmpty { "/" }) + (if (u.query != null) "?" + u.query else "") + + Socket().use { sock -> + sock.connect(InetSocketAddress(host, port), timeoutMs) + sock.soTimeout = timeoutMs + + val out: OutputStream = sock.getOutputStream() + val req = StringBuilder() + req.append("$method $path HTTP/1.1\r\n") + req.append("Host: $host${if (port == 80) "" else ":$port"}\r\n") + req.append("Connection: close\r\n") + req.append("Accept-Encoding: identity\r\n") + req.append("User-Agent: EmbedImage/1\r\n") + if (body != null) { + req.append("Content-Type: ${contentType ?: "application/octet-stream"}\r\n") + req.append("Content-Length: ${body.size}\r\n") + } + req.append("\r\n") + out.write(req.toString().toByteArray(Charsets.US_ASCII)) + if (body != null) out.write(body) + out.flush() + + val inp: InputStream = sock.getInputStream() + val all = ByteArrayOutputStream() + val buf = ByteArray(8192) + while (true) { + val n = inp.read(buf) + if (n <= 0) break + all.write(buf, 0, n) + } + val raw = all.toByteArray() + val sep = indexOfDoubleCRLF(raw) + ?: throw RuntimeException("malformed HTTP response (no header terminator)") + val headerStr = String(raw, 0, sep, Charsets.US_ASCII) + var bodyBytes = raw.copyOfRange(sep + 4, raw.size) + + val lines = headerStr.split("\r\n") + val statusLine = lines.firstOrNull() ?: throw RuntimeException("empty HTTP response") + val statusParts = statusLine.split(" ", limit = 3) + if (statusParts.size < 2) throw RuntimeException("bad status line: $statusLine") + val status = statusParts[1].toIntOrNull() + ?: throw RuntimeException("bad status code: ${statusParts[1]}") + + val isChunked = lines.drop(1).any { + val ix = it.indexOf(':') + if (ix < 0) false + else it.substring(0, ix).trim().equals("Transfer-Encoding", ignoreCase = true) + && it.substring(ix + 1).trim().contains("chunked", ignoreCase = true) + } + if (isChunked) bodyBytes = decodeChunked(bodyBytes) + return status to bodyBytes + } + } + + private fun indexOfDoubleCRLF(data: ByteArray): Int? { + var i = 0 + while (i <= data.size - 4) { + if (data[i] == 0x0D.toByte() && data[i + 1] == 0x0A.toByte() + && data[i + 2] == 0x0D.toByte() && data[i + 3] == 0x0A.toByte() + ) return i + i++ + } + return null + } + + private fun decodeChunked(input: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + var i = 0 + while (i < input.size) { + var j = i + while (j < input.size - 1 && !(input[j] == 0x0D.toByte() && input[j + 1] == 0x0A.toByte())) j++ + if (j >= input.size - 1) break + val sizeLine = String(input, i, j - i, Charsets.US_ASCII).substringBefore(';').trim() + val size = sizeLine.toIntOrNull(16) ?: break + i = j + 2 + if (size == 0) break + if (i + size > input.size) break + out.write(input, i, size) + i += size + 2 + } + return out.toByteArray() + } + + private fun bake( + inputPath: String, + whiteAlpha: Double, + brightness: Int, + contrast: Int, + gamma: Double, + previewMaxDim: Int, + ): String { + var src: Bitmap? = null + try { + val decodeOpts = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + if (previewMaxDim > 0) { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(inputPath, bounds) + val maxSide = max(bounds.outWidth, bounds.outHeight) + if (maxSide > 0) { + var sample = 1 + while (maxSide / (sample * 2) >= previewMaxDim) sample *= 2 + decodeOpts.inSampleSize = sample + } + } + src = BitmapFactory.decodeFile(inputPath, decodeOpts) + ?: throw RuntimeException("decode failed: $inputPath") + + val w = src.width + val h = src.height + + val isIdentity = whiteAlpha <= 0.0 + && brightness == 0 + && contrast == 0 + && kotlin.math.abs(gamma - 1.0) < 1e-6 + + val tag = if (previewMaxDim > 0) "prev" else "embed" + val outFile = File(reactApplicationContext.cacheDir, "${tag}_${System.currentTimeMillis()}.png") + + if (isIdentity) { + // Identity adjustments — skip the per-pixel loop. Saves real + // CPU on Manta when the Mac server already baked the look in. + FileOutputStream(outFile).use { stream -> + src.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + return outFile.absolutePath + } + + val pixels = IntArray(w * h) + src.getPixels(pixels, 0, w, 0, 0, w, h) + + val g = if (gamma <= 0.0) 1.0 else gamma + val invG = 1.0 / g + val gammaLut = IntArray(256) + for (i in 0..255) { + gammaLut[i] = (255.0 * (i / 255.0).pow(invG)).toInt().coerceIn(0, 255) + } + + val contrastFactor = ((contrast.coerceIn(-100, 100) + 100).toDouble()) / 100.0 + val brightnessAdjust = brightness.coerceIn(-100, 100) + + val wa = (whiteAlpha.coerceIn(0.0, 1.0) * 255).toInt() + val invWa = 255 - wa + + for (i in pixels.indices) { + val p = pixels[i] + val a = (p ushr 24) and 0xFF + if (a == 0) continue + var r = (p ushr 16) and 0xFF + var gC = (p ushr 8) and 0xFF + var b = p and 0xFF + + r = applyBC(r, contrastFactor, brightnessAdjust) + gC = applyBC(gC, contrastFactor, brightnessAdjust) + b = applyBC(b, contrastFactor, brightnessAdjust) + + r = gammaLut[r] + gC = gammaLut[gC] + b = gammaLut[b] + + if (wa > 0) { + r = (r * invWa + 255 * wa) / 255 + gC = (gC * invWa + 255 * wa) / 255 + b = (b * invWa + 255 * wa) / 255 + } + + pixels[i] = (a shl 24) or (r shl 16) or (gC shl 8) or b + } + + val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + out.setPixels(pixels, 0, w, 0, 0, w, h) + + FileOutputStream(outFile).use { stream -> + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + out.recycle() + return outFile.absolutePath + } finally { + src?.recycle() + } + } + + private fun applyBC(channel: Int, contrastFactor: Double, brightnessAdjust: Int): Int { + val v = ((channel - 128) * contrastFactor + 128 + brightnessAdjust).toInt() + return min(255, max(0, v)) + } + + @ReactMethod + fun cleanupCache(promise: Promise) { + try { + val dir = reactApplicationContext.cacheDir + var n = 0 + dir.listFiles()?.forEach { f -> + val name = f.name + if ((name.startsWith("prev_") || name.startsWith("embed_") || name.startsWith("dl_")) && + (name.endsWith(".png") || name.endsWith(".bin"))) { + if (f.delete()) n++ + } + } + promise.resolve(n) + } catch (e: Throwable) { + promise.reject("E_CLEANUP", e.message ?: e.toString(), e) + } + } + + // Persistent key/value config backed by SharedPreferences. The plugin's + // settings screen serializes its config as JSON under a single key. + private val prefs by lazy { + reactApplicationContext.getSharedPreferences("embedimage_prefs", android.content.Context.MODE_PRIVATE) + } + + // Cross-bundle pending button handoff. The host runs index.js once + // in a background context to register buttons + listener, and once + // more in a UI context when the view opens — they're SEPARATE RN + // bundles with disjoint module state. So the button press fired in + // the background can't be read via a JS-module variable in the UI + // bundle. SharedPreferences bridges them. + // + // The getter is synchronous (returns a Kotlin String) so App.tsx's + // useState initializer can read it before any render frame. + @ReactMethod(isBlockingSynchronousMethod = true) + fun getAndClearPendingButton(): String { + return try { + val v = prefs.getString("pendingButton", "") ?: "" + if (v.isNotEmpty()) prefs.edit().remove("pendingButton").apply() + v + } catch (_: Throwable) { + "" + } + } + + @ReactMethod + fun setPendingButton(id: Int, promise: Promise) { + try { + prefs.edit().putString("pendingButton", id.toString()).apply() + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_PENDING", e.message ?: e.toString(), e) + } + } + + @ReactMethod + fun getConfigValue(key: String, promise: Promise) { + try { + promise.resolve(prefs.getString(key, null)) + } catch (e: Throwable) { + promise.reject("E_CONFIG", e.message ?: e.toString(), e) + } + } + + // Inkling-style file logger. Creates parent dirs on demand; appends + // UTF-8 with a trailing newline. Best-effort: callers should swallow + // failures (e.g. when /sdcard isn't writable on a locked device). + @ReactMethod + fun appendFile(path: String, text: String, promise: Promise) { + try { + val f = File(path) + f.parentFile?.mkdirs() + FileOutputStream(f, true).use { it.write((text + "\n").toByteArray(Charsets.UTF_8)) } + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_APPEND", e.message ?: e.toString(), e) + } + } + + // Inkling-style two-image stitch compositor. Crops are 0..1 fractions + // of each source image; overlap is in source-pixel units (matches the + // editor's coordinate space). topLayerIndex picks which image draws + // on top in the overlap zone. + @ReactMethod + fun composeStitch( + img1Path: String, img2Path: String, + crop1Top: Double, crop1Bottom: Double, crop1Left: Double, crop1Right: Double, + crop2Top: Double, crop2Bottom: Double, crop2Left: Double, crop2Right: Double, + direction: String, + overlap: Int, + topLayerIndex: Int, + outPath: String, + promise: Promise, + ) { + var bm1: Bitmap? = null + var bm2: Bitmap? = null + var c1: Bitmap? = null + var c2: Bitmap? = null + var out: Bitmap? = null + try { + bm1 = BitmapFactory.decodeFile(img1Path) + ?: throw RuntimeException("decode failed: $img1Path") + bm2 = BitmapFactory.decodeFile(img2Path) + ?: throw RuntimeException("decode failed: $img2Path") + + c1 = cropBitmap(bm1, crop1Left, crop1Top, crop1Right, crop1Bottom) + c2 = cropBitmap(bm2, crop2Left, crop2Top, crop2Right, crop2Bottom) + + val isVert = direction.equals("vertical", ignoreCase = true) + val ovl = max(0, overlap) + + val totalW: Int + val totalH: Int + val pos1x: Int; val pos1y: Int + val pos2x: Int; val pos2y: Int + if (isVert) { + totalW = max(c1.width, c2.width) + totalH = c1.height + c2.height - ovl + pos1x = (totalW - c1.width) / 2; pos1y = 0 + pos2x = (totalW - c2.width) / 2; pos2y = c1.height - ovl + } else { + totalW = c1.width + c2.width - ovl + totalH = max(c1.height, c2.height) + pos1x = 0; pos1y = (totalH - c1.height) / 2 + pos2x = c1.width - ovl; pos2y = (totalH - c2.height) / 2 + } + + if (totalW <= 0 || totalH <= 0) { + throw RuntimeException("invalid composite size ${totalW}x${totalH}") + } + out = Bitmap.createBitmap(totalW, totalH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawColor(android.graphics.Color.WHITE) + + // Draw the bottom layer first, then the top layer over it. + val drawOrder = if (topLayerIndex == 0) listOf(1, 0) else listOf(0, 1) + for (idx in drawOrder) { + val bm = if (idx == 0) c1 else c2 + val x = if (idx == 0) pos1x else pos2x + val y = if (idx == 0) pos1y else pos2y + canvas.drawBitmap(bm, x.toFloat(), y.toFloat(), null) + } + + val file = File(outPath) + file.parentFile?.mkdirs() + FileOutputStream(file).use { stream -> + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + promise.resolve(outPath) + } catch (e: Throwable) { + promise.reject("E_STITCH", e.message ?: e.toString(), e) + } finally { + bm1?.recycle(); bm2?.recycle() + if (c1 != null && c1 !== bm1) c1.recycle() + if (c2 != null && c2 !== bm2) c2.recycle() + out?.recycle() + } + } + + private fun cropBitmap( + src: Bitmap, + left: Double, top: Double, right: Double, bottom: Double, + ): Bitmap { + val l = (left.coerceIn(0.0, 1.0) * src.width).toInt() + val t = (top.coerceIn(0.0, 1.0) * src.height).toInt() + val r = (right.coerceIn(0.0, 1.0) * src.width).toInt() + val b = (bottom.coerceIn(0.0, 1.0) * src.height).toInt() + val w = (src.width - l - r).coerceAtLeast(1) + val h = (src.height - t - b).coerceAtLeast(1) + if (l == 0 && t == 0 && w == src.width && h == src.height) return src + return Bitmap.createBitmap(src, l, t, w, h) + } + + // Composite the input PNG (which may have alpha) over a solid color + // background. Used by the lasso → stitch-layers flow when the user + // wants a flat (non-transparent) output. + @ReactMethod + fun flattenOntoBg( + inputPath: String, outPath: String, + r: Int, g: Int, b: Int, + promise: Promise, + ) { + var src: Bitmap? = null + var out: Bitmap? = null + try { + src = BitmapFactory.decodeFile(inputPath) + ?: throw RuntimeException("decode failed: $inputPath") + out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawColor(android.graphics.Color.rgb( + r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255), + )) + canvas.drawBitmap(src, 0f, 0f, null) + val file = File(outPath) + file.parentFile?.mkdirs() + FileOutputStream(file).use { stream -> + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + promise.resolve(outPath) + } catch (e: Throwable) { + promise.reject("E_FLATTEN", e.message ?: e.toString(), e) + } finally { + src?.recycle() + out?.recycle() + } + } + + @ReactMethod + fun setConfigValue(key: String, value: String?, promise: Promise) { + try { + val editor = prefs.edit() + if (value == null) editor.remove(key) else editor.putString(key, value) + editor.apply() + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_CONFIG", e.message ?: e.toString(), e) + } + } +} diff --git a/android/app/src/main/java/com/embedimage/StubPackage.kt b/android/app/src/main/java/com/embedimage/StubPackage.kt index ffd8d20..579e5ae 100644 --- a/android/app/src/main/java/com/embedimage/StubPackage.kt +++ b/android/app/src/main/java/com/embedimage/StubPackage.kt @@ -7,10 +7,34 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ReactShadowNode import com.facebook.react.uimanager.ViewManager -// Empty ReactPackage. Its only purpose is to make the build pipeline -// produce an app.npk so the plugin host loads the React Native runtime -// for this plugin. Embed Image uses only SDK modules at runtime. +// Registers native modules for the plugin. The package's existence also +// forces the build pipeline to produce an app.npk, which the plugin host +// requires to load the React Native runtime for this plugin. class StubPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List = emptyList() + init { + // The Supernote pluginhost runs with cleartext HTTP blocked by + // NetworkSecurityPolicy, so JS fetch() to the LAN Mac server fails + // with "cleartext HTTP traffic to X not permitted". Our own + // AndroidManifest setting doesn't apply because the policy is read + // from the host process. Force-flip the singleton at plugin load. + try { + val policy = android.security.NetworkSecurityPolicy.getInstance() + val cls = policy.javaClass + for (name in arrayOf("mCleartextTrafficPermitted", "DEFAULT_CLEARTEXT_TRAFFIC_PERMITTED")) { + try { + val f = cls.getDeclaredField(name) + f.isAccessible = true + f.setBoolean(policy, true) + } catch (_: NoSuchFieldException) { + // field name varies by Android version; keep trying. + } + } + } catch (e: Throwable) { + android.util.Log.w("EmbedImage", "cleartext override failed: $e") + } + } + + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(ImageProcessorModule(reactContext)) override fun createViewManagers(reactContext: ReactApplicationContext): List>> = emptyList() } diff --git a/index.js b/index.js index 32d1ebf..cddfb07 100644 --- a/index.js +++ b/index.js @@ -2,24 +2,127 @@ import { AppRegistry, Image } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import App from './App'; import { name as appName } from './app.json'; +import pluginConfig from './PluginConfig.json'; +import { runSendLassoToMac } from './src/lassoExport'; +import { runQuickStitch } from './src/quickStitch'; +import { loadStreamConfig } from './src/storage'; +import { FileLogger } from './src/util/FileLogger'; +import { setPendingButton } from './pendingButton'; AppRegistry.registerComponent(appName, () => App); PluginManager.init(); -const BUTTON_ID = 1; +FileLogger.raw('[embedimage] bundle start', { + version: pluginConfig.versionName, code: pluginConfig.versionCode, +}); + +// Button IDs. App.tsx watches the most recent press and routes to the +// matching headless screen if applicable. +export const BUTTON_MAIN = 1; +export const BUTTON_REFRESH = 2; +export const BUTTON_DROP = 3; +export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac +export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text +export const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso toolbar — layer picker → image +export const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar duplicate of above (always visible) +export const BUTTON_QUICK_STITCH_TRANSP = 8; // lasso toolbar — headless stitch (transparent BG) +export const BUTTON_QUICK_STITCH_WHITE = 9; // lasso toolbar — headless stitch (white BG) + +const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; -// type=1: sidebar button (plugins area). showType=1: tap opens the plugin view (App.tsx). -PluginManager.registerButton(1, ['NOTE'], { - id: BUTTON_ID, - name: 'Embed PNG', - icon: Image.resolveAssetSource(require('./assets/icon.png')).uri, +function mkName(label) { + return { en: label, zh_CN: label, zh_TW: label, ja: label }; +} + +const baseBtn = (id, label) => ({ + id, + name: label, + nameMap: mkName(label), + desc: '', + descMap: mkName(''), + icon: ICON, showType: 1, }); +// Helper that logs success/failure of each registration to the log +// file so the next adb pull tells us exactly what registered. +function regBtn(type, label, payload) { + return PluginManager.registerButton(type, ['NOTE'], payload) + .then((ok) => FileLogger.raw('[embedimage] registerButton OK', { id: payload.id, label, type, ok })) + .catch((e) => FileLogger.raw('[embedimage] registerButton FAIL', { id: payload.id, label, type, err: String(e?.message ?? e) })); +} + +regBtn(1, 'Embed Image', baseBtn(BUTTON_MAIN, 'Embed Image')); +regBtn(1, 'Refresh Embed', baseBtn(BUTTON_REFRESH, 'Refresh Embed')); +regBtn(1, 'Drop Inbox', baseBtn(BUTTON_DROP, 'Drop Inbox')); + +// Stitch Layers also lives as a SIDEBAR button. The lasso "..." popup +// in the Manta toolbar has limited visible space; with Sticker + our +// Send to Mac + Recognize already there, the lasso version may scroll +// off-screen and look "missing". The sidebar entry is always visible +// — lasso state is preserved when the plugin view opens, so the user +// can lasso → open sidebar → Stitch Layers and the picker still sees +// the selection. The lasso-toolbar version below stays registered for +// users who scroll to it. The two registrations need distinct ids; +// App.tsx routes both to the same LayerStitch screen. +regBtn(1, 'Stitch Layers', baseBtn(BUTTON_STITCH_LAYERS_SIDE, 'Stitch Layers')); + +// Lasso-toolbar buttons (type=2). The host's area-selection adapter +// expects `editDataTypes` and `nameMap` — without either it throws. +// editDataTypes values per the SDK: +// 0 handwritten strokes 1 title 2 image 3 text 4 link 5 shapes +regBtn(2, 'Send to Mac', { + ...baseBtn(BUTTON_LASSO_SEND, 'Send to Mac'), + editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, // headless — runs straight from the button listener +}); + +regBtn(2, 'Recognize', { + ...baseBtn(BUTTON_LASSO_RECOGNIZE, 'Recognize'), + editDataTypes: [0, 1, 3], // strokes / title / text +}); + +regBtn(2, 'Stitch Layers', { + ...baseBtn(BUTTON_LASSO_STITCH_LAYERS, 'Stitch Layers'), + editDataTypes: [0, 1, 2, 3, 4, 5], +}); + +// Headless "just do it" variants — same plumbing as Send to Mac +// (showType:0, runs straight from the button-press handler, no plugin +// view to open). Bypasses the routing race entirely. The view-based +// "Stitch Layers" picker stays for users who want layer selection. +regBtn(2, 'Stitch (transparent)', { + ...baseBtn(BUTTON_QUICK_STITCH_TRANSP, 'Stitch (transparent)'), + editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, +}); + +regBtn(2, 'Stitch (white BG)', { + ...baseBtn(BUTTON_QUICK_STITCH_WHITE, 'Stitch (white BG)'), + editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, +}); + PluginManager.registerButtonListener({ onButtonPress: (msg) => { - if (!msg || msg.id !== BUTTON_ID) return; - // showType=1 opens the view automatically; nothing else to do here. + FileLogger.raw('[embedimage] onButtonPress', msg); + if (msg?.id === BUTTON_LASSO_SEND) { + loadStreamConfig() + .then((cfg) => runSendLassoToMac(cfg.lassoFormat ?? 'png')) + .catch(() => {}); + return; + } + if (msg?.id === BUTTON_QUICK_STITCH_TRANSP) { + runQuickStitch(true).catch(() => {}); + return; + } + if (msg?.id === BUTTON_QUICK_STITCH_WHITE) { + runQuickStitch(false).catch(() => {}); + return; + } + if (msg?.id !== undefined && msg?.id !== null) { + setPendingButton(msg.id); + } }, }); diff --git a/macapp/README.md b/macapp/README.md new file mode 100644 index 0000000..fe3e003 --- /dev/null +++ b/macapp/README.md @@ -0,0 +1,59 @@ +# Supernote Screen-Capture Server (Mac) + +Streams a screen / region / window from your Mac to the **Live Capture** +screen of the Embed Image plugin on a Supernote Manta. The plugin polls +this app over HTTP on the LAN — 1 frame per second by default. + +## Run + +```bash +./macapp/run.sh +``` + +The script creates a `macapp/.venv`, installs `requirements.txt`, and +launches the Tk window. On first run macOS will prompt for **Screen +Recording** permission — grant it in System Settings → Privacy & Security +→ Screen & System Audio Recording, then relaunch. + +The window shows: + +- **Connection** — your LAN IP and port. Copy these into the Manta plugin + via Settings → Mac Capture Server. +- **Source** — Full screen, a draggable region, or a specific window from + Quartz's window list (click *Refresh* if you opened a new app). +- **Interval** — seconds per frame (0.2 .. 10). The plugin polls at its + own interval; the lower of the two effectively wins. +- **Start / Stop** — toggle the capture loop. Frames are kept in memory + only; no disk writes. +- **Log** — running activity from both server and GUI. + +Custom port: + +```bash +./macapp/run.sh --port 9100 +``` + +## HTTP API (for debugging) + +| Path | Method | Body | +|-------------|--------|------------------------------------------------| +| `/status` | GET | — | +| `/frame` | GET | latest PNG | +| `/start` | POST | — | +| `/stop` | POST | — | +| `/source` | POST | `{source: "screen"\|"region"\|"window", ...}` | +| `/interval` | POST | `{interval_sec: 1.0}` | +| `/windows` | GET | list of visible windows | + +`curl http://:9000/status` from the Mac confirms the server is +reachable before you point the plugin at it. + +## Why these libraries + +- **mss** — fast, no-fuss screen capture; works on macOS without Quartz + context juggling for full-screen / region grabs. +- **pyobjc-framework-Quartz** — enumerates visible windows so you can pick + by app/title instead of memorising rect coordinates. +- **Pillow** — PNG encoding of the captured frame. +- **Flask** — small HTTP surface served from a worker thread alongside the + Tk main loop. diff --git a/macapp/__init__.py b/macapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/macapp/__main__.py b/macapp/__main__.py new file mode 100644 index 0000000..fb690b0 --- /dev/null +++ b/macapp/__main__.py @@ -0,0 +1,17 @@ +"""Entry point: `python -m macapp` starts the GUI + capture server.""" +from __future__ import annotations + +import argparse + +from . import gui + + +def main(): + parser = argparse.ArgumentParser(description="Supernote screen-capture server (Mac)") + parser.add_argument("--port", type=int, default=9000, help="HTTP port (default 9000)") + args = parser.parse_args() + gui.main(port=args.port) + + +if __name__ == "__main__": + main() diff --git a/macapp/gui.py b/macapp/gui.py new file mode 100644 index 0000000..14409dd --- /dev/null +++ b/macapp/gui.py @@ -0,0 +1,344 @@ +"""Tkinter UI for the capture server. + +Lives in the main thread; the Flask + capture threads from `server.py` run +in the background. The UI lets you pick a source (full screen, region, or +a specific window), start/stop capture, change the interval, see the LAN +IP/port the plugin should target, and watch a rolling log. +""" +from __future__ import annotations + +import time +import tkinter as tk +from tkinter import ttk + +import mss +from PIL import Image, ImageTk + +from . import server + + +# ---- Region picker (embedded thumbnail) ---- + +REGION_PREVIEW_MAX = 640 + + +class RegionPicker: + """Modal window showing a downscaled screenshot; drag to select region. + + The user's screen is captured once and rendered into the dialog at + around 640px wide. Drag coordinates on the canvas are mapped back to + monitor pixels so the live capture loop crops the right area. + """ + + def __init__(self, parent, on_done): + self.on_done = on_done + + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(mon) + pil_full = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + + self.mon_x = int(mon["left"]) + self.mon_y = int(mon["top"]) + self.mon_w = int(mon["width"]) + self.mon_h = int(mon["height"]) + + scale = REGION_PREVIEW_MAX / max(self.mon_w, self.mon_h) + self.scale = scale + thumb_w = max(1, int(self.mon_w * scale)) + thumb_h = max(1, int(self.mon_h * scale)) + pil_thumb = pil_full.resize((thumb_w, thumb_h), Image.LANCZOS) + + self.root = tk.Toplevel(parent) + self.root.title("Select region") + self.root.transient(parent) + self.root.grab_set() + self.root.resizable(False, False) + + self.hint = ttk.Label( + self.root, + text="Drag on the preview to pick a region. Esc to cancel.", + foreground="#444", + ) + self.hint.pack(padx=8, pady=(8, 4)) + + self._photo = ImageTk.PhotoImage(pil_thumb) + self.canvas = tk.Canvas( + self.root, width=thumb_w, height=thumb_h, highlightthickness=1, + highlightbackground="#888", cursor="crosshair", + ) + self.canvas.create_image(0, 0, image=self._photo, anchor="nw") + self.canvas.pack(padx=8, pady=4) + + self.size_label = ttk.Label(self.root, text="(no selection)", foreground="#666") + self.size_label.pack(padx=8, pady=4) + + btn_row = ttk.Frame(self.root) + btn_row.pack(padx=8, pady=(0, 8), fill="x") + ttk.Button(btn_row, text="Cancel", command=self._cancel).pack(side="right", padx=4) + self.ok_btn = ttk.Button(btn_row, text="Use selection", command=self._confirm, state="disabled") + self.ok_btn.pack(side="right", padx=4) + ttk.Button(btn_row, text="Full screen", command=self._use_full).pack(side="left", padx=4) + + self.start = None + self.rect_id = None + self.selection = None # (cx0, cy0, cx1, cy1) in canvas pixels + + self.canvas.bind("", self._on_down) + self.canvas.bind("", self._on_move) + self.canvas.bind("", self._on_up) + self.root.bind("", lambda _: self._cancel()) + self.root.bind("", lambda _: self._confirm() if self.selection else None) + self.root.protocol("WM_DELETE_WINDOW", self._cancel) + + def _on_down(self, e): + self.start = (e.x, e.y) + if self.rect_id: + self.canvas.delete(self.rect_id) + self.rect_id = self.canvas.create_rectangle( + e.x, e.y, e.x, e.y, outline="#ff3", width=2, + ) + + def _on_move(self, e): + if self.start is None or self.rect_id is None: + return + self.canvas.coords(self.rect_id, self.start[0], self.start[1], e.x, e.y) + self._update_size_label(self.start[0], self.start[1], e.x, e.y) + + def _on_up(self, e): + if self.start is None: + return + x0, y0 = self.start + x1, y1 = e.x, e.y + cw = abs(x1 - x0) + ch = abs(y1 - y0) + if cw < 4 or ch < 4: + if self.rect_id: + self.canvas.delete(self.rect_id) + self.rect_id = None + self.selection = None + self.size_label.config(text="(too small — drag a bigger box)") + self.ok_btn.config(state="disabled") + return + self.selection = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + self._update_size_label(x0, y0, x1, y1) + self.ok_btn.config(state="normal") + + def _update_size_label(self, x0, y0, x1, y1): + cw = abs(x1 - x0) + ch = abs(y1 - y0) + sw = round(cw / self.scale) + sh = round(ch / self.scale) + self.size_label.config(text=f"selection: {sw}×{sh} px (preview {cw}×{ch})") + + def _to_monitor(self): + if not self.selection: + return None + cx0, cy0, cx1, cy1 = self.selection + return { + "x": self.mon_x + round(cx0 / self.scale), + "y": self.mon_y + round(cy0 / self.scale), + "w": round((cx1 - cx0) / self.scale), + "h": round((cy1 - cy0) / self.scale), + } + + def _confirm(self): + region = self._to_monitor() + self.root.destroy() + self.on_done(region) + + def _use_full(self): + self.root.destroy() + self.on_done({"x": self.mon_x, "y": self.mon_y, "w": self.mon_w, "h": self.mon_h}) + + def _cancel(self): + self.root.destroy() + self.on_done(None) + + +# ---- Main window ---- + +class App: + def __init__(self, port: int): + self.port = port + self.root = tk.Tk() + self.root.title("Supernote Capture Server") + self.root.geometry("520x520") + + self._build_ui() + self._refresh_windows() + self._poll_status() + + def _build_ui(self): + pad = {"padx": 8, "pady": 4} + + # Connection info + info_frame = ttk.LabelFrame(self.root, text="Connection") + info_frame.pack(fill="x", **pad) + self.ip_var = tk.StringVar(value=server.get_local_ip()) + self.port_var = tk.StringVar(value=str(self.port)) + ttk.Label(info_frame, text="LAN IP:").grid(row=0, column=0, sticky="w", padx=4, pady=4) + ttk.Label(info_frame, textvariable=self.ip_var, font=("Menlo", 12, "bold")).grid( + row=0, column=1, sticky="w", padx=4 + ) + ttk.Label(info_frame, text="Port:").grid(row=0, column=2, sticky="e", padx=4) + port_entry = ttk.Entry(info_frame, textvariable=self.port_var, width=8) + port_entry.grid(row=0, column=3, padx=4) + ttk.Button(info_frame, text="Apply", command=self._apply_port).grid(row=0, column=4, padx=4) + ttk.Label( + info_frame, + text="In the Manta plugin: Settings → Mac Capture Server.", + foreground="#555", + ).grid(row=1, column=0, columnspan=5, sticky="w", padx=4, pady=(0, 4)) + + # Source picker + src_frame = ttk.LabelFrame(self.root, text="Source") + src_frame.pack(fill="x", **pad) + self.source_var = tk.StringVar(value="screen") + ttk.Radiobutton( + src_frame, text="Full screen", variable=self.source_var, value="screen", + command=self._send_source, + ).grid(row=0, column=0, sticky="w", padx=4, pady=2) + ttk.Radiobutton( + src_frame, text="Region (drag to pick)", variable=self.source_var, value="region", + command=self._send_source, + ).grid(row=1, column=0, sticky="w", padx=4, pady=2) + ttk.Button(src_frame, text="Pick region…", command=self._pick_region).grid( + row=1, column=1, padx=4 + ) + self.region_label = ttk.Label(src_frame, text="(none)", foreground="#666") + self.region_label.grid(row=1, column=2, sticky="w", padx=4) + ttk.Radiobutton( + src_frame, text="Window:", variable=self.source_var, value="window", + command=self._send_source, + ).grid(row=2, column=0, sticky="w", padx=4, pady=2) + self.window_combo = ttk.Combobox(src_frame, state="readonly", width=48) + self.window_combo.grid(row=2, column=1, columnspan=2, sticky="w", padx=4) + self.window_combo.bind("<>", lambda _: self._send_source()) + ttk.Button(src_frame, text="Refresh", command=self._refresh_windows).grid( + row=2, column=3, padx=4 + ) + + # Interval + int_frame = ttk.LabelFrame(self.root, text="Interval") + int_frame.pack(fill="x", **pad) + self.interval_var = tk.DoubleVar(value=1.0) + ttk.Scale( + int_frame, from_=0.2, to=10.0, orient="horizontal", variable=self.interval_var, + command=lambda _: self._send_interval(), + ).pack(fill="x", padx=4, pady=4) + self.interval_label = ttk.Label(int_frame, text="1.0 s / frame") + self.interval_label.pack(padx=4, pady=(0, 4)) + + # Start/Stop + status + ctl_frame = ttk.Frame(self.root) + ctl_frame.pack(fill="x", **pad) + self.toggle_btn = ttk.Button(ctl_frame, text="Start", command=self._toggle) + self.toggle_btn.pack(side="left", padx=4) + self.status_label = ttk.Label(ctl_frame, text="stopped", foreground="#a00") + self.status_label.pack(side="left", padx=12) + self.frame_label = ttk.Label(ctl_frame, text="0 frames") + self.frame_label.pack(side="right", padx=4) + + # Log + log_frame = ttk.LabelFrame(self.root, text="Log") + log_frame.pack(fill="both", expand=True, **pad) + self.log_box = tk.Text(log_frame, height=10, wrap="word", state="disabled") + self.log_box.pack(fill="both", expand=True, padx=4, pady=4) + + # --- event handlers --- + + def _apply_port(self): + try: + self.port = int(self.port_var.get()) + except ValueError: + return + self._log("Port change requires restart of the app.") + + def _refresh_windows(self): + wins = server.list_windows() + items = [ + f"#{w['id']} [{w['owner']}] {w['title'][:40] or '(untitled)'} {w['w']}×{w['h']}" + for w in wins + ] + self._windows = wins + self.window_combo["values"] = items + if items and not self.window_combo.get(): + self.window_combo.current(0) + + def _pick_region(self): + def done(region): + if region: + self.region_label.config( + text=f"{region['w']}×{region['h']} @ {region['x']},{region['y']}" + ) + self._region = region + self.source_var.set("region") + self._send_source() + else: + self._log("region pick cancelled") + + RegionPicker(self.root, done) + + def _send_source(self): + src = self.source_var.get() + if src == "screen": + server.STATE.source = "screen" + server.STATE.monitor_index = 1 + elif src == "region": + r = getattr(self, "_region", None) + if r: + server.STATE.region = r + server.STATE.source = "region" + elif src == "window": + idx = self.window_combo.current() + if idx >= 0 and idx < len(self._windows): + server.STATE.window_id = self._windows[idx]["id"] + server.STATE.source = "window" + self._log(f"source -> {src}") + + def _send_interval(self): + v = round(float(self.interval_var.get()), 2) + server.STATE.interval_sec = v + self.interval_label.config(text=f"{v:.2f} s / frame") + + def _toggle(self): + server.STATE.running = not server.STATE.running + self._log("start" if server.STATE.running else "stop") + + def _log(self, msg: str): + ts = time.strftime("%H:%M:%S") + self.log_box.configure(state="normal") + self.log_box.insert("end", f"{ts} {msg}\n") + self.log_box.see("end") + self.log_box.configure(state="disabled") + + def _poll_status(self): + running = server.STATE.running + self.toggle_btn.configure(text="Stop" if running else "Start") + self.status_label.configure( + text="running" if running else "stopped", + foreground="#080" if running else "#a00", + ) + self.frame_label.configure(text=f"{server.STATE.frame_count} frames") + + # Mirror server-side log into the GUI. + srv_log = list(server.STATE.log) + if hasattr(self, "_log_cursor"): + new = srv_log[self._log_cursor:] + else: + new = srv_log + for entry in new: + self._log(f"[srv] {entry['msg']}") + self._log_cursor = len(srv_log) + + self.root.after(500, self._poll_status) + + def run(self): + self.root.mainloop() + + +def main(port: int): + server.start_background(port) + App(port=port).run() diff --git a/macapp/requirements.txt b/macapp/requirements.txt new file mode 100644 index 0000000..44a4f79 --- /dev/null +++ b/macapp/requirements.txt @@ -0,0 +1,9 @@ +Flask>=3.0 +mss>=9.0 +Pillow>=10.0 +pyobjc-framework-Quartz>=10.0 + +# Optional — required only for the /birefnet endpoint (background removal). +# Install separately when you want to use the Remove BG buttons: +# pip install torch torchvision transformers +# These pull ~2 GB of model + deps so they're kept out of the default install. diff --git a/macapp/run.sh b/macapp/run.sh new file mode 100755 index 0000000..eb201c8 --- /dev/null +++ b/macapp/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Bootstrap a venv and launch the capture server + GUI. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/.." + +if [[ ! -d macapp/.venv ]]; then + echo "Creating venv at macapp/.venv …" + python3 -m venv macapp/.venv +fi + +# shellcheck disable=SC1091 +source macapp/.venv/bin/activate + +if ! python -c "import flask, mss, PIL, Quartz" >/dev/null 2>&1; then + echo "Installing macapp/requirements.txt …" + pip install --upgrade pip >/dev/null + pip install -r macapp/requirements.txt +fi + +exec python -m macapp "$@" diff --git a/macapp/server.py b/macapp/server.py new file mode 100644 index 0000000..1dda005 --- /dev/null +++ b/macapp/server.py @@ -0,0 +1,793 @@ +"""Capture server + worker for the Supernote Embed Image plugin. + +Runs a small Flask HTTP server that the plugin polls for the most recent +screen capture. A background thread captures the configured source at a +configurable interval and stashes the latest PNG bytes in memory. + +Endpoints +--------- +GET /status -> JSON {running, source, interval_sec, ip, ...} +GET /frame -> PNG bytes of the latest captured frame +POST /start -> begin capture loop +POST /stop -> pause capture loop +POST /source {source, ...} -> change capture source (full screen, region, window) +POST /interval {interval_sec} -> change capture interval +POST /adjust {fade, brightness, contrast, gamma, dither} + -> apply tone adjustments to served frames +POST /resolution {mul} -> downscale factor (0.1..1.0) applied to frames +GET /windows -> JSON list of visible windows from Quartz +GET /preview-shot?max=600 -> one-off PNG screenshot of the primary + monitor for the Manta-side region picker +POST /birefnet -> request body is PNG image bytes; response + is PNG with background removed (composited + onto white). Requires `torch` and + `transformers` to be installed. +GET /drop/list -> JSON list of files dropped into the watch + folder, newest first +GET /drop/file?name= -> binary content of a dropped file +POST /drop/consume {name} -> mark a dropped file as consumed (renames + it out of the watch folder) +POST /sketch -> request body is PNG bytes (a lasso export + from the Manta); saves to ~/Drop with a + timestamped filename so the user can pick + it up +GET /presets -> JSON list of named adjustment presets +POST /presets {name, ...} -> save a preset (overwrites by name) +DELETE /presets/ -> remove a preset +""" +from __future__ import annotations + +import io +import json +import logging +import os +import socket +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import mss +from flask import Flask, jsonify, request, send_file +from PIL import Image, ImageEnhance + +# Persisted data lives here. Watch folder is what users drop / airdrop +# files into; presets file holds named adjustment combos. +DATA_DIR = Path.home() / "EmbedImage" +WATCH_DIR = DATA_DIR / "Drop" +CONSUMED_DIR = DATA_DIR / "Drop" / ".consumed" +PRESETS_PATH = DATA_DIR / "presets.json" +SKETCHES_DIR = DATA_DIR / "Sketches" +for _d in (DATA_DIR, WATCH_DIR, CONSUMED_DIR, SKETCHES_DIR): + _d.mkdir(parents=True, exist_ok=True) + +DROP_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".tiff", ".tif"} + + +# Adjustment ranges must match src/AdjustmentPanel.tsx on the plugin side: +# fade 0..100 (% blended toward white) +# brightness -100..100 +# contrast -100..100 +# gamma 0.2..3.0 (1.0 = identity) +# dither "none" | "fs1" | "fs4" | "atkinson" (Manta has 4-level gray) +_DEFAULT_ADJUST = { + "fade": 0, + "brightness": 0, + "contrast": 0, + "gamma": 1.0, + "dither": "none", +} + + +def _is_identity_adjust(a: dict) -> bool: + return ( + int(a.get("fade", 0)) == 0 + and int(a.get("brightness", 0)) == 0 + and int(a.get("contrast", 0)) == 0 + and abs(float(a.get("gamma", 1.0)) - 1.0) < 1e-6 + and str(a.get("dither", "none")) == "none" + ) + + +def _dither(img: Image.Image, mode: str) -> Image.Image: + """Quantize to a small palette using error-diffusion. PIL's built-in + Floyd-Steinberg via convert('1') / convert('P') is fine for most; + we expose 1-bit ('fs1') and 4-level grayscale ('fs4', the Manta's + native range) and Atkinson (classic Mac-look) implemented manually.""" + if mode == "fs1": + return img.convert("L").convert("1").convert("RGB") + if mode == "fs4": + # 4-level gray palette (black, dark gray, light gray, white). + pal_img = Image.new("P", (1, 1)) + pal = [0, 0, 0, 85, 85, 85, 170, 170, 170, 255, 255, 255] + [0] * (256 * 3 - 12) + pal_img.putpalette(pal) + g = img.convert("L").convert("RGB") + return g.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG).convert("RGB") + if mode == "atkinson": + import numpy as np + + arr = np.asarray(img.convert("L"), dtype=np.float32).copy() + h, w = arr.shape + for y in range(h): + for x in range(w): + old = arr[y, x] + new = 0.0 if old < 128 else 255.0 + arr[y, x] = new + err = (old - new) / 8.0 + for dx, dy in ((1, 0), (2, 0), (-1, 1), (0, 1), (1, 1), (0, 2)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + arr[ny, nx] += err + return Image.fromarray(arr.clip(0, 255).astype("uint8"), "L").convert("RGB") + return img + + +def _apply_adjust(img: Image.Image, a: dict) -> Image.Image: + """Apply brightness/contrast/gamma/fade/dither to an RGB PIL image.""" + if _is_identity_adjust(a): + return img + out = img + brightness = int(a.get("brightness", 0)) + contrast = int(a.get("contrast", 0)) + gamma = float(a.get("gamma", 1.0)) + fade = int(a.get("fade", 0)) + dither = str(a.get("dither", "none")) + + if brightness: + out = ImageEnhance.Brightness(out).enhance(1.0 + brightness / 100.0) + if contrast: + out = ImageEnhance.Contrast(out).enhance(1.0 + contrast / 100.0) + if abs(gamma - 1.0) > 1e-6 and gamma > 0: + inv = 1.0 / gamma + lut = [min(255, int(round(255.0 * (i / 255.0) ** inv))) for i in range(256)] + out = out.point(lut * len(out.getbands())) + if fade: + alpha = max(0, min(100, fade)) / 100.0 + white = Image.new("RGB", out.size, (255, 255, 255)) + out = Image.blend(out, white, alpha) + if dither and dither != "none": + out = _dither(out, dither) + return out + +try: + import Quartz # provided by pyobjc-framework-Quartz + HAS_QUARTZ = True +except ImportError: # pragma: no cover + HAS_QUARTZ = False + + +@dataclass +class CaptureState: + running: bool = False + source: str = "screen" # "screen" | "region" | "window" + monitor_index: int = 1 # mss is 1-indexed; 1 = primary + region: Optional[dict] = None # {x, y, w, h} + window_id: Optional[int] = None + interval_sec: float = 1.0 + resolution_mul: float = 1.0 # downscale factor applied to outgoing frames + adjust: dict = field(default_factory=lambda: dict(_DEFAULT_ADJUST)) + + last_frame: Optional[bytes] = None + last_frame_ts: float = 0.0 + frame_count: int = 0 + last_error: Optional[str] = None + + log: list = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock) + + +STATE = CaptureState() + + +def log(msg: str) -> None: + with STATE.lock: + STATE.log.append({"ts": time.time(), "msg": msg}) + if len(STATE.log) > 100: + STATE.log = STATE.log[-100:] + print(f"[capture] {msg}", flush=True) + + +def get_local_ip() -> str: + """Best-effort LAN IP detection without external services.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("10.255.255.255", 1)) + return s.getsockname()[0] + except Exception: + try: + return socket.gethostbyname(socket.gethostname()) + except Exception: + return "127.0.0.1" + finally: + s.close() + + +def list_windows() -> list: + if not HAS_QUARTZ: + return [] + options = ( + Quartz.kCGWindowListOptionOnScreenOnly + | Quartz.kCGWindowListExcludeDesktopElements + ) + windows = Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) + result = [] + for w in windows: + if w.get("kCGWindowLayer", 1) != 0: + continue + bounds = w.get("kCGWindowBounds", {}) + width = int(bounds.get("Width", 0)) + height = int(bounds.get("Height", 0)) + if width < 40 or height < 40: + continue + result.append( + { + "id": int(w.get("kCGWindowNumber", 0)), + "owner": w.get("kCGWindowOwnerName", "") or "", + "title": w.get("kCGWindowName", "") or "", + "x": int(bounds.get("X", 0)), + "y": int(bounds.get("Y", 0)), + "w": width, + "h": height, + } + ) + return result + + +def _window_bounds(window_id: int) -> Optional[dict]: + if not HAS_QUARTZ: + return None + windows = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionIncludingWindow, window_id + ) + if not windows: + return None + bounds = windows[0].get("kCGWindowBounds", {}) + return { + "left": int(bounds.get("X", 0)), + "top": int(bounds.get("Y", 0)), + "width": int(bounds.get("Width", 0)), + "height": int(bounds.get("Height", 0)), + } + + +def _capture_once(sct: mss.mss) -> Optional[bytes]: + """Capture according to STATE and return PNG bytes (or None on failure).""" + target: Optional[dict] = None + src = STATE.source + if src == "screen": + monitors = sct.monitors + idx = max(1, min(len(monitors) - 1, STATE.monitor_index)) if len(monitors) > 1 else 0 + target = monitors[idx] + elif src == "region" and STATE.region: + r = STATE.region + target = {"left": r["x"], "top": r["y"], "width": r["w"], "height": r["h"]} + elif src == "window" and STATE.window_id is not None: + target = _window_bounds(STATE.window_id) + if target is None or target["width"] == 0 or target["height"] == 0: + STATE.last_error = "window vanished" + return None + else: + STATE.last_error = "no source" + return None + + shot = sct.grab(target) + pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + mul = max(0.05, min(1.0, STATE.resolution_mul)) + if mul < 0.999: + new_w = max(1, int(round(pil.width * mul))) + new_h = max(1, int(round(pil.height * mul))) + pil = pil.resize((new_w, new_h), Image.LANCZOS) + pil = _apply_adjust(pil, dict(STATE.adjust)) + buf = io.BytesIO() + pil.save(buf, format="PNG", optimize=False) + return buf.getvalue() + + +def capture_loop() -> None: + log("capture loop started") + sct = mss.mss() + try: + while True: + if not STATE.running: + time.sleep(0.1) + continue + try: + png = _capture_once(sct) + if png is not None: + with STATE.lock: + STATE.last_frame = png + STATE.last_frame_ts = time.time() + STATE.frame_count += 1 + STATE.last_error = None + except Exception as e: # noqa: BLE001 + STATE.last_error = str(e) + log(f"capture error: {e}") + time.sleep(max(0.05, STATE.interval_sec)) + finally: + sct.close() + + +# ---- Flask app ---- + +app = Flask(__name__) +# Quiet the per-request access log; we have our own log. +logging.getLogger("werkzeug").setLevel(logging.WARNING) + + +def _primary_monitor() -> dict: + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + return { + "left": int(mon["left"]), + "top": int(mon["top"]), + "width": int(mon["width"]), + "height": int(mon["height"]), + } + + +@app.route("/status") +def status(): + mon = _primary_monitor() + with STATE.lock: + return jsonify( + { + "running": STATE.running, + "source": STATE.source, + "monitor_index": STATE.monitor_index, + "region": STATE.region, + "window_id": STATE.window_id, + "interval_sec": STATE.interval_sec, + "resolution_mul": STATE.resolution_mul, + "adjust": dict(STATE.adjust), + "frame_count": STATE.frame_count, + "has_frame": STATE.last_frame is not None, + "last_frame_ts": STATE.last_frame_ts, + "last_error": STATE.last_error, + "ip": get_local_ip(), + "monitor": mon, + "drop_count": len(_drop_list()), + "watch_folder": str(WATCH_DIR), + } + ) + + +@app.route("/frame") +def frame(): + with STATE.lock: + if STATE.last_frame is None: + return jsonify({"error": "no frame"}), 404 + body = STATE.last_frame + return body, 200, {"Content-Type": "image/png"} + + +@app.route("/start", methods=["POST"]) +def start(): + STATE.running = True + log("start") + return jsonify({"ok": True}) + + +@app.route("/stop", methods=["POST"]) +def stop(): + STATE.running = False + log("stop") + return jsonify({"ok": True}) + + +@app.route("/source", methods=["POST"]) +def set_source(): + body: dict[str, Any] = request.get_json(force=True, silent=True) or {} + src = body.get("source") + if src not in ("screen", "region", "window"): + return jsonify({"error": "invalid source"}), 400 + STATE.source = src + if src == "screen": + STATE.monitor_index = int(body.get("monitor_index", 1) or 1) + elif src == "region": + STATE.region = body.get("region") + elif src == "window": + STATE.window_id = int(body.get("window_id") or 0) or None + log(f"source -> {src} ({body})") + return jsonify({"ok": True}) + + +@app.route("/interval", methods=["POST"]) +def set_interval(): + body = request.get_json(force=True, silent=True) or {} + try: + v = float(body.get("interval_sec", 1.0)) + except (TypeError, ValueError): + return jsonify({"error": "invalid interval"}), 400 + STATE.interval_sec = max(0.05, min(60.0, v)) + log(f"interval -> {STATE.interval_sec}s") + return jsonify({"ok": True, "interval_sec": STATE.interval_sec}) + + +_VALID_DITHER = {"none", "fs1", "fs4", "atkinson"} + + +@app.route("/adjust", methods=["POST"]) +def set_adjust(): + body = request.get_json(force=True, silent=True) or {} + new = dict(_DEFAULT_ADJUST) + try: + if "fade" in body: + new["fade"] = max(0, min(100, int(body["fade"]))) + if "brightness" in body: + new["brightness"] = max(-100, min(100, int(body["brightness"]))) + if "contrast" in body: + new["contrast"] = max(-100, min(100, int(body["contrast"]))) + if "gamma" in body: + new["gamma"] = max(0.2, min(3.0, float(body["gamma"]))) + if "dither" in body: + d = str(body["dither"] or "none") + new["dither"] = d if d in _VALID_DITHER else "none" + except (TypeError, ValueError): + return jsonify({"error": "invalid adjust value"}), 400 + STATE.adjust = new + log(f"adjust -> {new}") + return jsonify({"ok": True, "adjust": new}) + + +@app.route("/resolution", methods=["POST"]) +def set_resolution(): + body = request.get_json(force=True, silent=True) or {} + try: + v = float(body.get("mul", body.get("resolution_mul", 1.0))) + except (TypeError, ValueError): + return jsonify({"error": "invalid resolution"}), 400 + STATE.resolution_mul = max(0.1, min(1.0, v)) + log(f"resolution -> {STATE.resolution_mul:.2f}") + return jsonify({"ok": True, "resolution_mul": STATE.resolution_mul}) + + +@app.route("/windows") +def windows(): + return jsonify(list_windows()) + + +@app.route("/preview-shot") +def preview_shot(): + """Single screenshot for the Manta region picker. Always full primary + monitor (no adjustments, no resolution mul applied), downscaled to a + max dimension. Response: PNG bytes. + + Headers: + X-Mon-Left / X-Mon-Top / X-Mon-Width / X-Mon-Height (mac pixels) + X-Scale (preview / mac) + + Query: + max preview max dimension (default 600) + """ + try: + max_dim = int(request.args.get("max", "600")) + except (TypeError, ValueError): + max_dim = 600 + max_dim = max(160, min(2000, max_dim)) + + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(mon) + pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + long_side = max(pil.width, pil.height) + scale = max_dim / long_side if long_side > max_dim else 1.0 + if scale < 0.999: + pil = pil.resize( + (max(1, int(pil.width * scale)), max(1, int(pil.height * scale))), + Image.LANCZOS, + ) + buf = io.BytesIO() + pil.save(buf, format="PNG", optimize=False) + headers = { + "Content-Type": "image/png", + "X-Mon-Left": str(int(mon["left"])), + "X-Mon-Top": str(int(mon["top"])), + "X-Mon-Width": str(int(mon["width"])), + "X-Mon-Height": str(int(mon["height"])), + "X-Scale": f"{scale:.6f}", + } + return buf.getvalue(), 200, headers + + +# ---- BiRefNet background removal (lazy-loaded) ---- + +_BIREFNET = None # cached {model, processor, device} +_BIREFNET_LOCK = threading.Lock() + + +def _load_birefnet(): + """Import torch/transformers lazily and cache the model. Returns the + cached dict on success or raises with a friendly message.""" + global _BIREFNET + with _BIREFNET_LOCK: + if _BIREFNET is not None: + return _BIREFNET + try: + import torch # type: ignore + from transformers import AutoModelForImageSegmentation # type: ignore + except ImportError as e: + raise RuntimeError( + "BiRefNet needs torch+transformers. Install with:\n" + " pip install torch torchvision transformers" + ) from e + log("loading BiRefNet (first call only — may take a while)…") + model = AutoModelForImageSegmentation.from_pretrained( + "ZhengPeng7/BiRefNet", trust_remote_code=True + ) + device = ( + "mps" if torch.backends.mps.is_available() + else "cuda" if torch.cuda.is_available() + else "cpu" + ) + # The published weights ship in fp16; on MPS this trips a + # "Input type (float) and bias type (c10::Half) should be the + # same" mismatch because torchvision's transforms produce fp32 + # tensors. Force the whole model to fp32 — it's a few hundred + # MB instead of a few hundred, and accuracy is unchanged. + model = model.to(torch.float32).to(device).eval() + log(f"BiRefNet ready on {device} (fp32)") + _BIREFNET = {"model": model, "device": device, "torch": torch} + return _BIREFNET + + +def _birefnet_remove(img: Image.Image, bg: str) -> Image.Image: + """Run BiRefNet; composite onto white (bg='white') or keep alpha + (bg='transparent').""" + state = _load_birefnet() + torch = state["torch"] + model = state["model"] + device = state["device"] + from torchvision import transforms # local import; torchvision came with torch + + tfm = transforms.Compose([ + transforms.Resize((1024, 1024)), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ]) + rgb = img.convert("RGB") + model_dtype = next(model.parameters()).dtype + inp = tfm(rgb).unsqueeze(0).to(device=device, dtype=model_dtype) + with torch.no_grad(): + preds = model(inp)[-1].sigmoid().to(dtype=torch.float32).cpu() + mask = preds[0].squeeze() + from PIL import Image as PI + mask_pil = transforms.ToPILImage()(mask).resize(rgb.size, PI.BILINEAR) + out = rgb.copy() + out.putalpha(mask_pil) + if bg == "transparent": + return out + flat = Image.new("RGB", out.size, (255, 255, 255)) + flat.paste(out, mask=out.split()[3]) + return flat + + +@app.route("/birefnet", methods=["POST"]) +def birefnet(): + """Body: raw image bytes (PNG/JPEG/etc). + Query: bg=white (default) | transparent.""" + bg = request.args.get("bg", "white").lower() + if bg not in ("white", "transparent"): + bg = "white" + raw = request.get_data(cache=False) + if not raw: + return jsonify({"error": "no image body"}), 400 + try: + src = Image.open(io.BytesIO(raw)) + src.load() + except Exception as e: # noqa: BLE001 + return jsonify({"error": f"decode failed: {e}"}), 400 + try: + out = _birefnet_remove(src, bg) + except RuntimeError as e: + log(f"birefnet not available: {e}") + return jsonify({"error": str(e)}), 503 + except Exception as e: # noqa: BLE001 + log(f"birefnet error: {e}") + return jsonify({"error": f"birefnet failed: {e}"}), 500 + buf = io.BytesIO() + out.save(buf, format="PNG", optimize=False) + return buf.getvalue(), 200, {"Content-Type": "image/png"} + + +# ---- Watch folder (drop / airdrop area) ---- + +def _drop_list() -> list: + items = [] + for p in WATCH_DIR.iterdir(): + if not p.is_file(): + continue + if p.name.startswith("."): + continue + if p.suffix.lower() not in DROP_EXTS: + continue + st = p.stat() + items.append({ + "name": p.name, + "size": st.st_size, + "mtime": st.st_mtime, + }) + items.sort(key=lambda x: -x["mtime"]) + return items + + +@app.route("/drop/list") +def drop_list(): + return jsonify({ + "folder": str(WATCH_DIR), + "items": _drop_list(), + }) + + +@app.route("/drop/file") +def drop_file(): + name = request.args.get("name", "") + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + path = WATCH_DIR / name + if not path.is_file(): + return jsonify({"error": "not found"}), 404 + return send_file(str(path), mimetype="application/octet-stream") + + +@app.route("/drop/consume", methods=["POST"]) +def drop_consume(): + body = request.get_json(force=True, silent=True) or {} + name = str(body.get("name", "")) + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + path = WATCH_DIR / name + if not path.is_file(): + return jsonify({"error": "not found"}), 404 + dst = CONSUMED_DIR / f"{int(time.time())}_{path.name}" + path.rename(dst) + log(f"drop consumed: {name}") + return jsonify({"ok": True}) + + +# ---- Sketches uploaded from the Manta (lasso -> Mac) ---- + +@app.route("/sketch", methods=["POST"]) +def sketch(): + raw = request.get_data(cache=False) + if not raw: + return jsonify({"error": "no body"}), 400 + name = request.args.get("name", "") + if not name: + name = f"manta_{time.strftime('%Y%m%d_%H%M%S')}.png" + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + + # If the plugin sent crop coordinates (the lasso rect, in note pixel + # coordinates that match the rendered page), crop to just that area. + # Otherwise save the whole page as-is. + try: + cl = int(request.args.get("cropL", "")) if request.args.get("cropL") else None + ct = int(request.args.get("cropT", "")) if request.args.get("cropT") else None + cr = int(request.args.get("cropR", "")) if request.args.get("cropR") else None + cb = int(request.args.get("cropB", "")) if request.args.get("cropB") else None + except ValueError: + cl = ct = cr = cb = None + + # Optional output format. The Manta always uploads PNG bytes; if the + # user picked JPEG in Settings, we re-encode here. + fmt_q = (request.args.get("format", "") or "").lower().strip() + fmt: str + if fmt_q in ("jpg", "jpeg"): + fmt = "JPEG" + elif fmt_q == "png": + fmt = "PNG" + elif name.lower().endswith((".jpg", ".jpeg")): + fmt = "JPEG" + else: + fmt = "PNG" + + out_bytes = raw + needs_reencode = (fmt == "JPEG") + + if cl is not None and ct is not None and cr is not None and cb is not None and cr > cl and cb > ct: + try: + img = Image.open(io.BytesIO(raw)) + iw, ih = img.size + # Clamp to image bounds. + cl_c = max(0, min(iw, cl)) + ct_c = max(0, min(ih, ct)) + cr_c = max(0, min(iw, cr)) + cb_c = max(0, min(ih, cb)) + if cr_c > cl_c and cb_c > ct_c: + cropped = img.crop((cl_c, ct_c, cr_c, cb_c)) + buf = io.BytesIO() + if fmt == "JPEG": + cropped.convert("RGB").save(buf, format="JPEG", quality=92, optimize=True) + else: + cropped.save(buf, format="PNG", optimize=False) + out_bytes = buf.getvalue() + needs_reencode = False + log(f"sketch cropped {iw}x{ih} -> {cr_c - cl_c}x{cb_c - ct_c} ({fmt})") + except Exception as e: # noqa: BLE001 + log(f"sketch crop failed (saving full page): {e}") + + if needs_reencode: + try: + img = Image.open(io.BytesIO(raw)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG", quality=92, optimize=True) + out_bytes = buf.getvalue() + except Exception as e: # noqa: BLE001 + log(f"sketch JPEG encode failed (saving as PNG): {e}") + + # Make sure the filename extension matches the actual format. + if fmt == "JPEG" and not name.lower().endswith((".jpg", ".jpeg")): + name = name.rsplit(".", 1)[0] + ".jpg" + elif fmt == "PNG" and not name.lower().endswith(".png"): + name = name.rsplit(".", 1)[0] + ".png" + + path = SKETCHES_DIR / name + path.write_bytes(out_bytes) + log(f"sketch saved: {path} ({len(out_bytes)} bytes, {fmt})") + return jsonify({"ok": True, "path": str(path), "name": name, "format": fmt}) + + +# ---- Adjustment presets ---- + +def _read_presets() -> list: + if not PRESETS_PATH.exists(): + return [] + try: + data = json.loads(PRESETS_PATH.read_text()) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def _write_presets(items: list) -> None: + PRESETS_PATH.write_text(json.dumps(items, indent=2)) + + +@app.route("/presets") +def get_presets(): + return jsonify(_read_presets()) + + +@app.route("/presets", methods=["POST"]) +def save_preset(): + body = request.get_json(force=True, silent=True) or {} + name = str(body.get("name", "")).strip() + if not name: + return jsonify({"error": "name required"}), 400 + preset = { + "name": name, + "fade": int(body.get("fade", 0)), + "brightness": int(body.get("brightness", 0)), + "contrast": int(body.get("contrast", 0)), + "gamma": float(body.get("gamma", 1.0)), + "dither": str(body.get("dither", "none")), + } + items = [p for p in _read_presets() if p.get("name") != name] + items.append(preset) + _write_presets(items) + log(f"preset saved: {name}") + return jsonify({"ok": True, "preset": preset, "presets": items}) + + +@app.route("/presets/", methods=["DELETE"]) +def delete_preset(name): + items = [p for p in _read_presets() if p.get("name") != name] + _write_presets(items) + log(f"preset deleted: {name}") + return jsonify({"ok": True, "presets": items}) + + +def run_server(host: str, port: int) -> None: + from werkzeug.serving import make_server + + log(f"server listening on http://{host}:{port}") + server = make_server(host, port, app, threaded=True) + server.serve_forever() + + +def start_background(port: int) -> None: + threading.Thread(target=capture_loop, daemon=True).start() + threading.Thread(target=run_server, args=("0.0.0.0", port), daemon=True).start() diff --git a/pendingButton.d.ts b/pendingButton.d.ts new file mode 100644 index 0000000..036f40e --- /dev/null +++ b/pendingButton.d.ts @@ -0,0 +1,3 @@ +export function setPendingButton(id: number | null): void; +export function checkPendingButton(): number | null; +export function peekPendingButton(): number | null; diff --git a/pendingButton.js b/pendingButton.js new file mode 100644 index 0000000..81c6e11 --- /dev/null +++ b/pendingButton.js @@ -0,0 +1,42 @@ +// Synchronous routing channel from index.js's button listener to +// App.tsx's initial render. The complication: the host runs index.js +// twice — once in a background context (button registrations + press +// listener) and again in a fresh UI context when the view opens. They +// have disjoint JS module state, so a plain in-memory variable here +// would be reset by the time App.tsx renders. +// +// We therefore back it with SharedPreferences via a synchronous native +// method, with an in-memory cache for same-context navigation. +import { NativeModules } from 'react-native'; + +const { ImageProcessor } = NativeModules; + +let pendingButtonId = null; + +export const setPendingButton = (id) => { + pendingButtonId = id; + // Fire-and-forget; the value lives in SharedPreferences so the UI + // context can read it even if our JS module state was reset. + try { + ImageProcessor?.setPendingButton?.(id); + } catch (_) {} +}; + +// Read AND clear. Called by App.tsx on first mount. Tries the native +// store first (works across context boundaries) then falls back to +// the in-memory variable (works for re-mounts within the same context). +export const checkPendingButton = () => { + let val = null; + try { + const raw = ImageProcessor?.getAndClearPendingButton?.(); + if (typeof raw === 'string' && raw.length > 0) { + const n = parseInt(raw, 10); + if (!Number.isNaN(n)) val = n; + } + } catch (_) {} + if (val === null) val = pendingButtonId; + pendingButtonId = null; + return val; +}; + +export const peekPendingButton = () => pendingButtonId; diff --git a/src/AdjustmentPanel.tsx b/src/AdjustmentPanel.tsx new file mode 100644 index 0000000..14b2f28 --- /dev/null +++ b/src/AdjustmentPanel.tsx @@ -0,0 +1,338 @@ +import React, { useEffect, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { RangeSlider } from './RangeSlider'; +import { lanHttp, lanJson } from './imageProcessor'; +import { baseUrl, loadStreamConfig } from './storage'; +import { theme } from './ui/theme'; +import { Win95Button, Win95InsetPanel } from './ui/Win95'; +import type { Adjustments, DitherMode, Preset } from './types'; +import { DEFAULT_ADJUSTMENTS, DITHER_LABELS } from './types'; + +type NumericKey = 'fade' | 'brightness' | 'contrast' | 'gamma'; + +type Config = { + label: string; + min: number; + max: number; + step: number; + default: number; + presets: number[]; + format: (v: number) => string; +}; + +const ADJUSTMENT_CONFIG: Record = { + fade: { label: 'Fade', min: 0, max: 100, step: 5, default: 0, presets: [0, 25, 50, 75, 100], format: (v) => `${Math.round(v)}%` }, + brightness: { label: 'Brightness', min: -100, max: 100, step: 5, default: 0, presets: [-50, -25, 0, 25, 50], format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}` }, + contrast: { label: 'Contrast', min: -100, max: 100, step: 5, default: 0, presets: [-50, -25, 0, 25, 50], format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}` }, + gamma: { label: 'Gamma', min: 0.5, max: 2.0, step: 0.05, default: 1.0, presets: [0.5, 0.75, 1.0, 1.5, 2.0], format: (v) => v.toFixed(2) }, +}; + +const DITHER_MODES: DitherMode[] = ['none', 'fs1', 'fs4', 'atkinson']; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function equalish(a: number, b: number): boolean { + return Math.abs(a - b) < 1e-6; +} + +export function AdjustmentPanel({ + values, + onChange, + disabled, +}: { + values: Adjustments; + onChange: (next: Adjustments) => void; + disabled?: boolean; +}): React.JSX.Element { + const [activeKey, setActiveKey] = useState('fade'); + const [presets, setPresets] = useState([]); + const [showSave, setShowSave] = useState(false); + const [newPresetName, setNewPresetName] = useState(''); + + const cfg = ADJUSTMENT_CONFIG[activeKey]; + const value = values[activeKey]; + const setValue = (v: number) => onChange({ ...values, [activeKey]: v }); + const round = (v: number) => Math.round(v / cfg.step) * cfg.step; + + // Load presets from the Mac when mounted (best-effort). + useEffect(() => { + (async () => { + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) return; + try { + const list: any = await lanJson('GET', `${url}/presets`, undefined, 3000); + if (Array.isArray(list)) setPresets(list); + } catch { + // server may not be reachable; presets stay empty. + } + })(); + }, []); + + const applyPreset = (p: Preset) => { + onChange({ + fade: p.fade ?? 0, + brightness: p.brightness ?? 0, + contrast: p.contrast ?? 0, + gamma: p.gamma ?? 1.0, + dither: (p.dither ?? 'none') as DitherMode, + }); + }; + + const saveCurrentAsPreset = async () => { + const name = newPresetName.trim(); + if (!name) return; + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) { + setShowSave(false); + return; + } + try { + const res: any = await lanHttp('POST', `${url}/presets`, { name, ...values }, 3000); + const parsed = JSON.parse(res?.body ?? '{}'); + if (Array.isArray(parsed?.presets)) setPresets(parsed.presets); + } catch { + // ignore — UI keeps working without preset persistence. + } + setShowSave(false); + setNewPresetName(''); + }; + + const deletePreset = async (name: string) => { + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) return; + try { + await lanHttp('DELETE', `${url}/presets/${encodeURIComponent(name)}`, undefined, 3000); + const list: any = await lanJson('GET', `${url}/presets`, undefined, 3000); + if (Array.isArray(list)) setPresets(list); + } catch { + // ignore + } + }; + + const resetAll = () => onChange(DEFAULT_ADJUSTMENTS); + + return ( + + {/* Preset chips */} + + Presets: + + {presets.length === 0 ? ( + (none — Save current… to add) + ) : ( + presets.map((p) => ( + applyPreset(p)} + onLongPress={() => deletePreset(p.name)} + style={styles.presetChip} + disabled={disabled} + > + {p.name} + + )) + )} + + setShowSave((s) => !s)} disabled={disabled}>Save… + Reset + + + {showSave ? ( + + + + + OK + setShowSave(false)}>X + + ) : null} + + {/* Tab strip */} + + {(Object.keys(ADJUSTMENT_CONFIG) as NumericKey[]).map((key) => { + const c = ADJUSTMENT_CONFIG[key]; + const active = key === activeKey; + const dirty = !equalish(values[key], c.default); + return ( + setActiveKey(key)} + style={[styles.tab, active && styles.tabActive]} + disabled={disabled} + > + + {c.label}{dirty ? ' •' : ''} + + + ); + })} + + + {/* Active control */} + + {cfg.label} + {cfg.format(value)} + setValue(cfg.default)} disabled={disabled}>Reset + + + + setValue(clamp(round(value - cfg.step), cfg.min, cfg.max))} + disabled={disabled} + >− + + + + setValue(clamp(round(value + cfg.step), cfg.min, cfg.max))} + disabled={disabled} + >+ + + + + {cfg.presets.map((p) => { + const active = equalish(p, value); + return ( + setValue(p)} + style={[styles.quickPreset, active && styles.quickPresetActive]} + disabled={disabled} + > + + {cfg.format(p)} + + + ); + })} + + + {/* Dither selector */} + + Dither: + {DITHER_MODES.map((m) => { + const active = values.dither === m; + return ( + onChange({ ...values, dither: m })} + style={[styles.ditherChip, active && styles.ditherChipActive]} + disabled={disabled} + > + + {DITHER_LABELS[m]} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + root: { backgroundColor: theme.bg, borderTopWidth: 1, borderTopColor: theme.shadow }, + presetBar: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 4, + }, + presetBarLabel: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + presetChips: { alignItems: 'center', gap: 6, paddingRight: 6 }, + presetEmpty: { fontFamily: 'VT323', fontSize: 13, color: theme.shadow, paddingHorizontal: 4 }, + presetChip: { + paddingHorizontal: 8, paddingVertical: 4, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + presetChipTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + saveRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingBottom: 4, + }, + saveInput: { flex: 1, padding: 0 }, + saveInputTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text, paddingHorizontal: 6, paddingVertical: 4 }, + tabRow: { + flexDirection: 'row', + backgroundColor: theme.bg, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, + tab: { + flex: 1, paddingVertical: 6, alignItems: 'center', justifyContent: 'center', + }, + tabActive: { backgroundColor: theme.titleBg }, + tabTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + tabTxtActive: { color: theme.titleFg }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 6, paddingTop: 6, + }, + headerLabel: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + headerValue: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + sliderRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 4, + }, + sliderWrap: { flex: 1 }, + quickPresetRow: { + flexDirection: 'row', gap: 4, flexWrap: 'wrap', + paddingHorizontal: 6, paddingVertical: 4, + }, + quickPreset: { + paddingHorizontal: 8, paddingVertical: 3, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + quickPresetActive: { + backgroundColor: theme.titleBg, + borderTopColor: theme.dark, borderLeftColor: theme.dark, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + quickPresetTxt: { fontFamily: 'VT323', fontSize: 13, color: theme.text }, + quickPresetTxtActive: { color: theme.titleFg }, + ditherRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, + ditherLabel: { fontFamily: 'VT323', fontSize: 14, color: theme.text, marginRight: 4 }, + ditherChip: { + paddingHorizontal: 8, paddingVertical: 4, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + ditherChipActive: { + backgroundColor: theme.titleBg, + borderTopColor: theme.dark, borderLeftColor: theme.dark, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + ditherChipTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + ditherChipTxtActive: { color: theme.titleFg }, +}); diff --git a/src/RangeSlider.tsx b/src/RangeSlider.tsx new file mode 100644 index 0000000..35114a5 --- /dev/null +++ b/src/RangeSlider.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { PanResponder, StyleSheet, View } from 'react-native'; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function RangeSlider({ + value, + min, + max, + step, + onChange, + disabled, +}: { + value: number; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + disabled?: boolean; +}): React.JSX.Element { + const [width, setWidth] = useState(0); + const widthRef = useRef(width); + widthRef.current = width; + + const update = useCallback( + (x: number) => { + const w = Math.max(1, widthRef.current); + const pct = clamp(x / w, 0, 1); + const raw = min + pct * (max - min); + const snapped = Math.round(raw / step) * step; + onChange(clamp(snapped, min, max)); + }, + [min, max, step, onChange], + ); + + const responder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled, + onMoveShouldSetPanResponder: () => !disabled, + onPanResponderGrant: (e) => update(e.nativeEvent.locationX), + onPanResponderMove: (e) => update(e.nativeEvent.locationX), + }), + ).current; + + const pct = clamp((value - min) / (max - min), 0, 1) * 100; + + return ( + setWidth(e.nativeEvent.layout.width)} + {...responder.panHandlers} + > + + + + + ); +} + +const styles = StyleSheet.create({ + track: { height: 36, justifyContent: 'center' }, + rail: { + position: 'absolute', left: 0, right: 0, top: 16, height: 4, + backgroundColor: '#ddd', + }, + fill: { + position: 'absolute', left: 0, top: 16, height: 4, + backgroundColor: '#000', + }, + thumb: { + position: 'absolute', top: 6, width: 24, height: 24, + marginLeft: -12, borderRadius: 12, + backgroundColor: '#000', borderWidth: 2, borderColor: '#fff', + }, +}); diff --git a/src/StatusDot.tsx b/src/StatusDot.tsx new file mode 100644 index 0000000..12ce4ca --- /dev/null +++ b/src/StatusDot.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { theme } from './ui/theme'; +import { ConnStatus } from './useConnStatus'; + +// 8x8 pixel-art "LED" indicator for the Mac-server connection. Manta is +// monochrome but the bevels read clearly: filled = connected, hollow = +// disconnected, no border = unknown. +export function StatusDot({ status }: { status: ConnStatus }): React.JSX.Element { + const fill = status === 'connected' ? theme.text : theme.inset; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + bezel: { + width: 14, height: 14, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + alignItems: 'center', justifyContent: 'center', + marginLeft: 8, + }, + dot: { width: 8, height: 8 }, +}); diff --git a/src/embedTracker.ts b/src/embedTracker.ts new file mode 100644 index 0000000..2e57668 --- /dev/null +++ b/src/embedTracker.ts @@ -0,0 +1,219 @@ +import { PluginCommAPI, PluginFileAPI, PluginNoteAPI } from 'sn-plugin-lib'; +import { saveEmbedTrack } from './storage'; +import { EmbedTrack } from './types'; +import { FileLogger } from './util/FileLogger'; + +// Tracks the most recently embedded Picture element so the live-capture +// flow can replace it in place when a new frame comes in. +// +// Flow on first insert: +// 1. insertImage(path) — uses the host's default position. +// 2. getLastElement() — picks up the new Picture element so we know its +// uuid, numInPage, layerNum, and rect. +// 3. saveCurrentNote(). +// +// Flow on replace: +// 1. getElements() and find ours by uuid — the rect may have moved if the +// user dragged the lasso around in the meantime. +// 2. deleteElements([oldNumInPage]). +// 3. insertElements with a fresh Picture element at the same rect, layer. +// 4. getLastElement() again to refresh the tracker. +// 5. saveCurrentNote(). + +type ApiResponse = { success: boolean; result?: T; error?: { code: number; message: string } }; + +async function getCurrentContext(): Promise<{ notePath: string; page: number } | null> { + try { + const pathRes: any = await PluginCommAPI.getCurrentFilePath(); + const pageRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = pathRes?.result ?? pathRes?.filePath ?? pathRes; + const page = pageRes?.result ?? pageRes?.pageNum ?? pageRes; + if (typeof notePath !== 'string' || typeof page !== 'number') return null; + return { notePath, page }; + } catch { + return null; + } +} + +function fromElement(notePath: string, page: number, el: any): EmbedTrack | null { + if (!el || !el.picture) return null; + const rect = el.picture.rect; + if (!rect) return null; + return { + notePath, + page, + layerNum: el.layerNum ?? 0, + numInPage: el.numInPage ?? 0, + uuid: el.uuid ?? '', + rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }, + }; +} + +export async function insertAndTrack(pngPath: string): Promise { + const res: ApiResponse = (await PluginNoteAPI.insertImage(pngPath)) as any; + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { + await PluginNoteAPI.saveCurrentNote(); + } catch {} + const ctx = await getCurrentContext(); + if (!ctx) return null; + let track: EmbedTrack | null = null; + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const el = lastRes?.result ?? lastRes; + track = fromElement(ctx.notePath, ctx.page, el); + } catch { + track = null; + } + if (track) await saveEmbedTrack(track).catch(() => {}); + return track; +} + +// Re-fetch the tracked element so we pick up any rect changes from the lasso, +// AND return the raw SDK element so callers can clone it verbatim. Picture +// elements have a bunch of fields (maxX, maxY, thickness, status, +// recognizeResult, ...) that the host validates on insertElements — we +// learned the hard way that omitting them gets HTTP 106 "Invalid API +// parameters". +async function refreshTrackAndRaw( + track: EmbedTrack, +): Promise<{ track: EmbedTrack; raw: any | null }> { + if (!track.uuid) return { track, raw: null }; + try { + const res: any = await PluginFileAPI.getElements(track.page, track.notePath); + const list: any[] = res?.result ?? (Array.isArray(res) ? res : []); + const found = list.find((e) => e?.uuid === track.uuid); + if (!found) return { track, raw: null }; + const fresh = fromElement(track.notePath, track.page, found); + return { track: fresh ?? track, raw: found }; + } catch { + return { track, raw: null }; + } +} + +export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { + const { track: fresh, raw } = await refreshTrackAndRaw(track); + FileLogger.raw('[embedimage] replaceInPlace start', { + notePath: fresh.notePath, page: fresh.page, + numInPage: fresh.numInPage, layerNum: fresh.layerNum, + uuid: fresh.uuid, rect: fresh.rect, newPngPath, + rawKeys: raw ? Object.keys(raw) : null, + }); + + // Clone the existing element to keep maxX/maxY/thickness/layout fields + // intact, but strip everything that describes the OLD picture's data + // (uuid/numInPage are obvious; recognizeResult/status/contoursSrc/ + // angles are derived from the old picture and confuse the host). + let newElement: any; + if (raw) { + newElement = JSON.parse(JSON.stringify(raw)); + for (const k of [ + 'uuid', 'numInPage', 'recognizeResult', 'status', + 'contoursSrc', 'angles', + ]) { + delete newElement[k]; + } + if (newElement.picture) { + newElement.picture.picturePath = newPngPath; + newElement.picture.rect = { ...fresh.rect }; + } else { + newElement.picture = { picturePath: newPngPath, rect: { ...fresh.rect } }; + } + } else { + newElement = { + type: 200, + pageNum: fresh.page, + layerNum: fresh.layerNum, + picture: { + picturePath: newPngPath, + rect: { ...fresh.rect }, + }, + }; + } + FileLogger.raw('[embedimage] replaceInPlace newElement keys:', Object.keys(newElement)); + + let insertOk = false; + let insertErr: any = null; + try { + const ins: any = await PluginFileAPI.insertElements( + fresh.notePath, fresh.page, [newElement], + ); + FileLogger.raw('[embedimage] insertElements ->', JSON.stringify(ins)); + if (ins && ins.success !== false) { + insertOk = true; + } else { + insertErr = ins?.error?.message ?? 'insertElements rejected'; + } + } catch (e: any) { + insertErr = e?.message ?? String(e); + FileLogger.raw('[embedimage] insertElements threw:', insertErr); + } + + let movedTo: EmbedTrack | null = null; + if (!insertOk) { + // Fallback: use insertImage (host picks default position, but it + // always works) then modifyElements to move the new picture to + // fresh.rect. modifyElements doesn't re-load a picture's bitmap, + // but it *does* honor rect updates, so this gives us refresh-in- + // place even when insertElements rejects our payload shape. + FileLogger.raw('[embedimage] insertElements failed (', insertErr, ') — trying insertImage + move'); + const img: any = await PluginNoteAPI.insertImage(newPngPath); + FileLogger.raw('[embedimage] insertImage ->', JSON.stringify(img)); + if (!img || img.success === false) { + throw new Error(`insert failed (original embed kept): ${img?.error?.message ?? insertErr ?? 'unknown'}`); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + // Get the new picture's identity so we can move it. + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const newEl = lastRes?.result ?? lastRes; + FileLogger.raw('[embedimage] insertImage placed element:', JSON.stringify(newEl)?.slice(0, 300)); + if (newEl?.uuid && typeof newEl?.numInPage === 'number') { + const moveEl: any = { + ...newEl, + picture: { ...(newEl.picture ?? {}), rect: { ...fresh.rect } }, + }; + const moveRes: any = await PluginFileAPI.modifyElements( + fresh.notePath, fresh.page, [moveEl], + ); + FileLogger.raw('[embedimage] modifyElements (move) ->', JSON.stringify(moveRes)); + movedTo = fromElement(fresh.notePath, fresh.page, newEl); + } + } catch (e: any) { + FileLogger.raw('[embedimage] move-after-insertImage threw:', e?.message ?? e); + } + } + + try { + const del: any = await PluginFileAPI.deleteElements( + fresh.notePath, fresh.page, [fresh.numInPage], + ); + FileLogger.raw('[embedimage] deleteElements ->', JSON.stringify(del)); + } catch (e: any) { + FileLogger.raw('[embedimage] deleteElements threw:', e?.message ?? e); + // Non-fatal: user has two pictures, better than zero. + } + + try { + await PluginNoteAPI.saveCurrentNote(); + } catch (e: any) { + FileLogger.raw('[embedimage] saveCurrentNote threw:', e?.message ?? e); + } + + let newTrack: EmbedTrack | null = movedTo; + if (!newTrack) { + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const el = lastRes?.result ?? lastRes; + FileLogger.raw('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); + newTrack = fromElement(fresh.notePath, fresh.page, el); + } catch (e: any) { + FileLogger.raw('[embedimage] getLastElement threw:', e?.message ?? e); + newTrack = null; + } + } + if (newTrack) await saveEmbedTrack(newTrack).catch(() => {}); + return newTrack; +} diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts new file mode 100644 index 0000000..0f4e94c --- /dev/null +++ b/src/imageProcessor.ts @@ -0,0 +1,161 @@ +import { NativeModules } from 'react-native'; +import type { Adjustments } from './types'; + +type ImageProcessorNative = { + processForEmbed: ( + inputPath: string, + whiteAlpha: number, + brightness: number, + contrast: number, + gamma: number, + previewMaxDim: number, + ) => Promise; + downloadAndProcess: ( + url: string, + whiteAlpha: number, + brightness: number, + contrast: number, + gamma: number, + previewMaxDim: number, + timeoutMs: number, + ) => Promise; + // Cleartext-safe HTTP for LAN endpoints. Bypasses Android's + // NetworkSecurityPolicy (the pluginhost blocks our JS fetch()). + nativeHttp: ( + method: string, + url: string, + bodyJson: string | null, + timeoutMs: number, + ) => Promise<{ status: number; body: string }>; + nativeHttpPostFile: ( + url: string, + inputPath: string, + contentType: string, + timeoutMs: number, + ) => Promise; + appendFile: (path: string, text: string) => Promise; + composeStitch: ( + img1Path: string, img2Path: string, + crop1Top: number, crop1Bottom: number, crop1Left: number, crop1Right: number, + crop2Top: number, crop2Bottom: number, crop2Left: number, crop2Right: number, + direction: 'vertical' | 'horizontal', + overlap: number, + topLayerIndex: number, + outPath: string, + ) => Promise; + flattenOntoBg: ( + inputPath: string, outPath: string, r: number, g: number, b: number, + ) => Promise; + cleanupCache: () => Promise; + getConfigValue: (key: string) => Promise; + setConfigValue: (key: string, value: string | null) => Promise; +}; + +const native = (NativeModules as { ImageProcessor?: ImageProcessorNative }).ImageProcessor; + +export const ImageProcessor = native; + +export function adjustmentsAreDefault(a: Adjustments): boolean { + return ( + a.fade === 0 && + a.brightness === 0 && + a.contrast === 0 && + Math.abs(a.gamma - 1.0) < 1e-6 && + (a.dither ?? 'none') === 'none' + ); +} + +export async function bakeFile( + inputPath: string, + a: Adjustments, + previewMaxDim: number, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.processForEmbed( + inputPath, + a.fade / 100, + a.brightness, + a.contrast, + a.gamma, + previewMaxDim, + ); +} + +export async function downloadAndBake( + url: string, + a: Adjustments, + previewMaxDim: number, + timeoutMs: number = 5000, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.downloadAndProcess( + url, + a.fade / 100, + a.brightness, + a.contrast, + a.gamma, + previewMaxDim, + timeoutMs, + ); +} + +export async function lanHttp( + method: 'GET' | 'POST' | 'DELETE' | 'PUT', + url: string, + body?: object, + timeoutMs: number = 3000, +): Promise<{ status: number; body: string }> { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.nativeHttp(method, url, body ? JSON.stringify(body) : null, timeoutMs); +} + +export async function lanJson( + method: 'GET' | 'POST' | 'DELETE' | 'PUT', + url: string, + body?: object, + timeoutMs: number = 3000, +): Promise { + const res = await lanHttp(method, url, body, timeoutMs); + if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`); + return JSON.parse(res.body) as T; +} + +// POST a local file's bytes to a URL and save the binary response to a +// new cache file. Returns the output file path. +export async function lanPostFile( + url: string, + inputPath: string, + contentType: string = 'image/png', + timeoutMs: number = 60000, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.nativeHttpPostFile(url, inputPath, contentType, timeoutMs); +} + +export type ImageCrop = { cropTop: number; cropBottom: number; cropLeft: number; cropRight: number }; +export type StitchImage = { path: string; width: number; height: number; crop: ImageCrop }; +export type StitchParams = { direction: 'vertical' | 'horizontal'; overlap: number; topLayerIndex: number }; + +// Flatten a (potentially transparent) PNG over a solid background color. +export async function flattenOntoBg( + inputPath: string, outPath: string, rgb: { r: number; g: number; b: number }, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.flattenOntoBg(inputPath, outPath, rgb.r, rgb.g, rgb.b); +} + +// Composes two cropped images into one PNG. Returns the output path. +export async function composeStitch( + a: StitchImage, b: StitchImage, params: StitchParams, outPath: string, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.composeStitch( + a.path, b.path, + a.crop.cropTop, a.crop.cropBottom, a.crop.cropLeft, a.crop.cropRight, + b.crop.cropTop, b.crop.cropBottom, b.crop.cropLeft, b.crop.cropRight, + params.direction, + Math.max(0, Math.round(params.overlap)), + params.topLayerIndex === 0 ? 0 : 1, + outPath, + ); +} diff --git a/src/lassoExport.ts b/src/lassoExport.ts new file mode 100644 index 0000000..5772d5f --- /dev/null +++ b/src/lassoExport.ts @@ -0,0 +1,125 @@ +import { FileUtils, NativeUIUtils, PluginCommAPI, PluginManager } from 'sn-plugin-lib'; +import { lanPostFile } from './imageProcessor'; +import { baseUrl, loadStreamConfig } from './storage'; +import { FileLogger } from './util/FileLogger'; + +// Cribbed from jpmoo/lassoexport. The reliable lasso → PNG pipeline: +// PluginCommAPI.saveStickerByLasso(stickerPath) +// PluginCommAPI.getStickerSize(stickerPath) -> {width, height} +// PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size) +// `.sticker` is the host's internal lasso archive; the thumbnail call +// rasterizes it to a PNG we can read like any other file. +// +// We then upload that PNG to the Mac's /sketch endpoint. The Mac decides +// whether to save as PNG or JPEG based on the ?format=… query string. + +function unwrap(value: any, what: string): T { + if (!value || value.success === false) { + const msg = value?.error?.message ?? `${what} failed`; + throw new Error(msg); + } + return value.result as T; +} + +function deriveBaseName(notePath: string): string { + const last = notePath.split('/').pop() || 'note'; + const noExt = last.replace(/\.[^.]+$/, ''); + const safe = noExt.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return safe.length > 0 ? safe : 'note'; +} + +async function exportLassoToPng(): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmedPlugin = pluginDir.replace(/\/+$/, ''); + + // Clear stale lasso data and any leftover .sticker files from prior runs. + try { PluginCommAPI.clearElementCache(); } catch {} + try { + const existing: any = await FileUtils.listFiles(pluginDir); + if (Array.isArray(existing)) { + for (const entry of existing) { + const name = typeof entry === 'string' ? entry : entry?.path; + if (typeof name === 'string' && name.endsWith('.sticker')) { + try { await FileUtils.deleteFile(name.startsWith('/') ? name : `${trimmedPlugin}/${name}`); } catch {} + } + } + } + } catch {} + + let baseName = 'note'; + try { + const notePath = unwrap(await PluginCommAPI.getCurrentFilePath(), 'getCurrentFilePath'); + baseName = deriveBaseName(notePath); + } catch {} + + const stamp = Date.now(); + const stickerPath = `${trimmedPlugin}/sticker-${stamp}.sticker`; + const pngPath = `${trimmedPlugin}/lasso-${baseName}-${stamp}.png`; + + FileLogger.raw('[embedimage] exportLassoToPng saveStickerByLasso ->', stickerPath); + unwrap(await PluginCommAPI.saveStickerByLasso(stickerPath), 'saveStickerByLasso'); + if (!(await FileUtils.exists(stickerPath))) { + throw new Error('sticker file was not written (lasso may be empty)'); + } + + const size = unwrap<{ width: number; height: number }>( + await PluginCommAPI.getStickerSize(stickerPath), + 'getStickerSize', + ); + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + FileLogger.raw('[embedimage] exportLassoToPng size=', size); + + unwrap( + await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size), + 'generateStickerThumbnail', + ); + if (!(await FileUtils.exists(pngPath))) { + throw new Error('PNG was not written'); + } + + // Drop the .sticker; we only needed the PNG. + try { await FileUtils.deleteFile(stickerPath); } catch {} + + return pngPath; +} + +export type LassoFormat = 'png' | 'jpg'; + +// Headless entry point — runs from index.js with no view opening. +// Reports progress + result via Android Toast and an error dialog so +// the user gets feedback without a React surface. +export async function runSendLassoToMac(format: LassoFormat = 'png'): Promise { + FileLogger.raw('[embedimage] runSendLassoToMac start format=', format); + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + try { NativeUIUtils.showRattaDialog('No Mac server set.\nOpen Embed Image → Settings.', '', 'OK', false); } catch {} + return; + } + + const pngPath = await exportLassoToPng(); + FileLogger.raw('[embedimage] runSendLassoToMac pngPath=', pngPath); + + // Filename extension drives format selection on the Mac side. + const baseStamp = new Date().toISOString().replace(/[:.]/g, '-'); + const name = `manta_${baseStamp}.${format}`; + const qs = `?name=${encodeURIComponent(name)}&format=${format}`; + const url2 = `${url}/sketch${qs}`; + FileLogger.raw('[embedimage] runSendLassoToMac POST', url2); + await lanPostFile(url2, pngPath, 'image/png', 30000); + FileLogger.raw('[embedimage] runSendLassoToMac done'); + + try { + const ToastAndroid = require('react-native').ToastAndroid; + ToastAndroid.show(`Sent to Mac as ${name}`, ToastAndroid.LONG); + } catch {} + } catch (e: any) { + const msg = e?.message ?? String(e); + FileLogger.raw('[embedimage] runSendLassoToMac failed:', msg); + try { NativeUIUtils.showRattaDialog(`Send failed: ${msg}`, '', 'OK', false); } catch {} + } +} diff --git a/src/lassoRecognize.ts b/src/lassoRecognize.ts new file mode 100644 index 0000000..bd4d058 --- /dev/null +++ b/src/lassoRecognize.ts @@ -0,0 +1,110 @@ +import { PluginCommAPI, PluginFileAPI } from 'sn-plugin-lib'; +import { FileLogger } from './util/FileLogger'; + +// Inkling-style lasso → text extraction. Picks up any selected handwritten +// strokes, runs the host's OCR (recognizeElements), and returns the +// recognized text plus a suggested insertion rect (below the last text +// box in the selection, if any, otherwise below the lasso itself). + +export type RecognizeResult = { + text: string; + insertRect?: { left: number; top: number; right: number; bottom: number }; + stats: { strokes: number; textBoxes: number; pictures: number; others: number }; +}; + +export async function recognizeLasso(): Promise { + const out: RecognizeResult = { + text: '', + stats: { strokes: 0, textBoxes: 0, pictures: 0, others: 0 }, + }; + + const lassoRes: any = await PluginCommAPI.getLassoElements(); + FileLogger.log('Recognize', 'getLassoElements ->', { success: lassoRes?.success, count: Array.isArray(lassoRes?.result) ? lassoRes.result.length : 0 }); + if (!lassoRes?.success || !Array.isArray(lassoRes?.result)) { + throw new Error(lassoRes?.error?.message ?? 'getLassoElements failed'); + } + const elements: any[] = lassoRes.result; + + const strokes: any[] = []; + const textParts: string[] = []; + let lastTextBoxRect: { left: number; top: number; right: number; bottom: number } | undefined; + + for (const el of elements) { + switch (el?.type) { + case 0: // stroke + out.stats.strokes++; + strokes.push(el); + break; + case 500: + case 501: + case 502: { // text boxes + out.stats.textBoxes++; + const content: string = el.textBox?.textContentFull ?? ''; + if (content.trim()) textParts.push(content.trim()); + const r = el.textBox?.textRect; + if (r && (!lastTextBoxRect || r.bottom > lastTextBoxRect.bottom)) { + lastTextBoxRect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + } + break; + } + case 200: + out.stats.pictures++; + break; + default: + out.stats.others++; + break; + } + } + FileLogger.log('Recognize', `lasso stats`, out.stats); + + // OCR the handwritten strokes (if any) via the host's recognizer. + if (strokes.length > 0) { + try { + const fpRes: any = await PluginCommAPI.getCurrentFilePath(); + const pgRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; + const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; + if (typeof notePath === 'string' && typeof page === 'number') { + const psRes: any = await PluginFileAPI.getPageSize(notePath, page); + FileLogger.log('Recognize', 'getPageSize ->', psRes); + if (psRes?.success && psRes.result) { + const ocr: any = await PluginCommAPI.recognizeElements(strokes, psRes.result); + FileLogger.log('Recognize', 'recognizeElements ->', { success: ocr?.success, len: typeof ocr?.result === 'string' ? ocr.result.length : 0 }); + if (ocr?.success && typeof ocr.result === 'string' && ocr.result.trim()) { + textParts.push(ocr.result.trim()); + } + } + } + } catch (e: any) { + FileLogger.log('Recognize', 'OCR threw', e?.message ?? String(e)); + } + } + + out.text = textParts.join('\n'); + + // Pick an insertion rect. Prefer right below the last text box; fall + // back to right below the lasso bounding box. + try { + if (lastTextBoxRect) { + out.insertRect = { + left: lastTextBoxRect.left, + top: lastTextBoxRect.bottom + 10, + right: lastTextBoxRect.right, + bottom: lastTextBoxRect.bottom + 90, + }; + } else { + const lr: any = await PluginCommAPI.getLassoRect(); + const r = lr?.result; + if (r) { + out.insertRect = { + left: r.left, top: r.bottom + 10, + right: r.right, bottom: r.bottom + 90, + }; + } + } + } catch { + // insertRect optional; caller can fall back. + } + + return out; +} diff --git a/src/layerStitch.ts b/src/layerStitch.ts new file mode 100644 index 0000000..b9ab5ad --- /dev/null +++ b/src/layerStitch.ts @@ -0,0 +1,182 @@ +import { PluginCommAPI, PluginFileAPI, PluginManager } from 'sn-plugin-lib'; +import { flattenOntoBg } from './imageProcessor'; +import { FileLogger } from './util/FileLogger'; + +// Inkling/jpmoo-inspired: take the current lasso selection, optionally +// filter to a subset of note layers, render to a sticker via +// convertElement2Sticker, then rasterize that to a PNG via +// generateStickerThumbnail. Optionally flatten the (transparent) +// rasterization onto a solid background. + +export type LayerInfo = { + layerId: number; // 0..3 user layers + name: string; + isCurrent: boolean; + isVisible: boolean; + count: number; // # of lassoed elements on this layer +}; + +export type LassoSurvey = { + notePath: string; + page: number; + rect: { left: number; top: number; right: number; bottom: number }; + layers: LayerInfo[]; + elementsByLayer: Map; +}; + +export async function surveyLasso(): Promise { + const fpRes: any = await PluginCommAPI.getCurrentFilePath(); + const pgRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; + const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; + if (typeof notePath !== 'string' || typeof page !== 'number') return null; + + const rectRes: any = await PluginCommAPI.getLassoRect(); + if (!rectRes?.success || !rectRes.result) return null; + const rect = rectRes.result; + + const elRes: any = await PluginCommAPI.getLassoElements(); + if (!elRes?.success || !Array.isArray(elRes.result)) return null; + const elements: any[] = elRes.result; + + const elementsByLayer = new Map(); + for (const el of elements) { + const ln = (el?.layerNum ?? 0) as number; + if (!elementsByLayer.has(ln)) elementsByLayer.set(ln, []); + elementsByLayer.get(ln)!.push(el); + } + + // Try getLayers first — gives us proper names + visibility. If it + // fails or returns nothing usable, fall back to whatever layerNums + // appear in the lasso so the user still has something to pick from. + let rawLayers: any[] = []; + try { + const lyRes: any = await PluginFileAPI.getLayers(notePath, page); + if (lyRes?.success && Array.isArray(lyRes.result)) { + rawLayers = lyRes.result; + } + FileLogger.log('LayerStitch', 'getLayers ->', { success: lyRes?.success, n: rawLayers.length }); + } catch (e: any) { + FileLogger.log('LayerStitch', 'getLayers threw', e?.message ?? String(e)); + } + + let layers: LayerInfo[] = rawLayers + .map((l: any) => { + const id = (l.layerId !== undefined ? l.layerId : l.layerNum) as number; + return { + layerId: id, + name: typeof l.name === 'string' && l.name.length > 0 ? l.name : `Layer ${id + 1}`, + isCurrent: !!l.isCurrentLayer, + isVisible: l.isVisible !== false, + count: elementsByLayer.get(id)?.length ?? 0, + }; + }) + .filter((l) => l.layerId >= 0) + .sort((a, b) => a.layerId - b.layerId); + + if (layers.length === 0) { + // Fallback: infer layers from the lassoed elements' layerNums. + // Always include layer 0 (main) so the user has at least one row. + const ids = new Set([0]); + for (const ln of elementsByLayer.keys()) { + if (ln >= 0) ids.add(ln); + } + layers = Array.from(ids) + .sort((a, b) => a - b) + .map((id) => ({ + layerId: id, + name: `Layer ${id + 1}`, + isCurrent: id === 0, + isVisible: true, + count: elementsByLayer.get(id)?.length ?? 0, + })); + FileLogger.log('LayerStitch', 'using inferred layers', { ids: Array.from(ids) }); + } + + FileLogger.log('LayerStitch', 'surveyLasso', { + notePath, page, rect, layerCount: layers.length, totalEls: elements.length, + elementsByLayer: Object.fromEntries( + Array.from(elementsByLayer.entries()).map(([k, v]) => [k, v.length]), + ), + }); + + return { notePath, page, rect, layers, elementsByLayer }; +} + +export type ComposeOpts = { + selectedLayerIds: number[]; + transparentBg: boolean; +}; + +// Produce a PNG that contains only elements from `selectedLayerIds` +// (within the current lasso). Returns the absolute file path. The +// caller is responsible for inserting/sending. +export async function composeLassoLayers( + survey: LassoSurvey, opts: ComposeOpts, +): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmed = pluginDir.replace(/\/+$/, ''); + const stamp = Date.now(); + const stickerPath = `${trimmed}/layerstitch-${stamp}.sticker`; + const pngPath = `${trimmed}/layerstitch-${stamp}.png`; + + const wanted = new Set(opts.selectedLayerIds); + const picked: any[] = []; + for (const [layerId, els] of survey.elementsByLayer.entries()) { + if (wanted.has(layerId)) picked.push(...els); + } + if (picked.length === 0) throw new Error('No elements selected (pick at least one layer with content)'); + + FileLogger.log('LayerStitch', 'composing', { + pickedCount: picked.length, layers: opts.selectedLayerIds, transparent: opts.transparentBg, + }); + + // Clear stale sticker file from a prior run so we don't overwrite a + // locked handle. + try { PluginCommAPI.clearElementCache(); } catch {} + + const deviceType = await PluginManager.getDeviceType().catch(() => 5); + const convertRes: any = await PluginCommAPI.convertElement2Sticker({ + machineType: deviceType, elements: picked, stickerPath, + }); + FileLogger.log('LayerStitch', 'convertElement2Sticker ->', convertRes); + if (!convertRes?.success) { + throw new Error(convertRes?.error?.message ?? 'convertElement2Sticker failed'); + } + + const sizeRes: any = await PluginCommAPI.getStickerSize(stickerPath); + FileLogger.log('LayerStitch', 'getStickerSize ->', sizeRes); + const size = sizeRes?.result; + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + + const thumbRes: any = await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size); + FileLogger.log('LayerStitch', 'generateStickerThumbnail ->', thumbRes); + if (thumbRes?.success === false) { + throw new Error(thumbRes?.error?.message ?? 'generateStickerThumbnail failed'); + } + + if (opts.transparentBg) { + return pngPath; // already has transparent BG (sticker renders so) + } + const flatPath = `${trimmed}/layerstitch-${stamp}-flat.png`; + await flattenOntoBg(pngPath, flatPath, { r: 255, g: 255, b: 255 }); + FileLogger.log('LayerStitch', 'flattened ->', flatPath); + return flatPath; +} + +// Insert the composed PNG back at the lasso location. Uses +// PluginNoteAPI.insertImage (host picks default position) as the +// reliable fallback — we tried positioned insertElements earlier and +// it kept rejecting picture payloads. +export async function insertComposed(pngPath: string): Promise { + const { PluginNoteAPI } = await import('sn-plugin-lib'); + const res: any = await PluginNoteAPI.insertImage(pngPath); + FileLogger.log('LayerStitch', 'insertImage ->', res); + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} +} diff --git a/src/quickStitch.ts b/src/quickStitch.ts new file mode 100644 index 0000000..8b81b8a --- /dev/null +++ b/src/quickStitch.ts @@ -0,0 +1,90 @@ +import { FileUtils, NativeUIUtils, PluginCommAPI, PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { flattenOntoBg } from './imageProcessor'; +import { FileLogger } from './util/FileLogger'; + +// Headless lasso → sticker → PNG, inserted into the note. Same +// pipeline jpmoo's lassoexport uses (which is the only one that +// reliably works for "save lasso as image"). Runs entirely from the +// button-press handler in index.js — no plugin view, no routing race. +// +// Two flavours exposed below: transparent background (drop straight in, +// nothing covers existing content) and white background (handy when +// pasting into a darker page or shipping to a viewer that doesn't +// handle alpha well). + +async function exportLasso(transparentBg: boolean): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmed = pluginDir.replace(/\/+$/, ''); + const stamp = Date.now(); + const stickerPath = `${trimmed}/qstitch-${stamp}.sticker`; + const pngPath = `${trimmed}/qstitch-${stamp}.png`; + const flatPath = `${trimmed}/qstitch-${stamp}-flat.png`; + + // Clean any leftover stickers from prior runs. + try { PluginCommAPI.clearElementCache(); } catch {} + try { + const existing: any = await FileUtils.listFiles(pluginDir); + if (Array.isArray(existing)) { + for (const entry of existing) { + const name = typeof entry === 'string' ? entry : entry?.path; + if (typeof name === 'string' && name.endsWith('.sticker')) { + try { await FileUtils.deleteFile(name.startsWith('/') ? name : `${trimmed}/${name}`); } catch {} + } + } + } + } catch {} + + FileLogger.raw('[embedimage] QuickStitch saveStickerByLasso ->', stickerPath); + const saveRes: any = await PluginCommAPI.saveStickerByLasso(stickerPath); + FileLogger.raw('[embedimage] QuickStitch saveStickerByLasso result:', JSON.stringify(saveRes)); + if (!saveRes?.success) { + throw new Error(saveRes?.error?.message ?? 'saveStickerByLasso failed (lasso may be empty)'); + } + + const sizeRes: any = await PluginCommAPI.getStickerSize(stickerPath); + FileLogger.raw('[embedimage] QuickStitch getStickerSize ->', JSON.stringify(sizeRes)); + const size = sizeRes?.result; + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + + const thumbRes: any = await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size); + FileLogger.raw('[embedimage] QuickStitch generateStickerThumbnail ->', JSON.stringify(thumbRes)); + if (thumbRes?.success === false) { + throw new Error(thumbRes?.error?.message ?? 'generateStickerThumbnail failed'); + } + + if (transparentBg) { + try { await FileUtils.deleteFile(stickerPath); } catch {} + return pngPath; + } + await flattenOntoBg(pngPath, flatPath, { r: 255, g: 255, b: 255 }); + try { await FileUtils.deleteFile(stickerPath); } catch {} + return flatPath; +} + +export async function runQuickStitch(transparentBg: boolean): Promise { + FileLogger.raw('[embedimage] runQuickStitch start', { transparentBg }); + try { + const pngPath = await exportLasso(transparentBg); + FileLogger.raw('[embedimage] runQuickStitch composed ->', pngPath); + const ins: any = await PluginNoteAPI.insertImage(pngPath); + FileLogger.raw('[embedimage] runQuickStitch insertImage ->', JSON.stringify(ins)); + if (!ins || ins.success === false) { + throw new Error(ins?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + try { + const ToastAndroid = require('react-native').ToastAndroid; + ToastAndroid.show( + `Stitched lasso (${transparentBg ? 'transparent' : 'white'} BG)`, + ToastAndroid.LONG, + ); + } catch {} + } catch (e: any) { + const msg = e?.message ?? String(e); + FileLogger.raw('[embedimage] runQuickStitch failed:', msg); + try { NativeUIUtils.showRattaDialog(`Quick Stitch failed: ${msg}`, '', 'OK', false); } catch {} + } +} diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx new file mode 100644 index 0000000..6c8496b --- /dev/null +++ b/src/screens/Browser.tsx @@ -0,0 +1,407 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { FileUtils, PluginManager, RattaFileSelector } from 'sn-plugin-lib'; +import { StatusDot } from '../StatusDot'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { MenuBar, StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { useConnStatus } from '../useConnStatus'; +import type { Entry, EntryKind, SortKey, StreamConfig } from '../types'; +import { DEFAULT_STREAM_CONFIG } from '../types'; +import { makeSession, StitchSession } from './StitchEditor'; +import type { StitchImage } from '../imageProcessor'; + +const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; +const INTERNAL_ROOT = '/storage/emulated/0'; +const DEFAULT_DIR = INTERNAL_ROOT + '/Document/Images'; +const MIN_COLUMNS = 2; +const MAX_COLUMNS = 6; +const DEFAULT_COLUMNS = 3; + +const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: 'date_desc', label: 'Newest' }, + { key: 'date_asc', label: 'Oldest' }, + { key: 'name', label: 'Name' }, +]; + +function basename(path: string): string { + const i = path.lastIndexOf('/'); + return i >= 0 ? path.slice(i + 1) : path; +} + +function parentDir(p: string): string { + const trimmed = p.replace(/\/+$/, ''); + const i = trimmed.lastIndexOf('/'); + if (i <= 0) return '/'; + return trimmed.slice(0, i); +} + +function classifyEntry(name: string): EntryKind | null { + const lower = name.toLowerCase(); + if (IMAGE_EXTS.some((e) => lower.endsWith(e))) return 'image'; + const dot = lower.lastIndexOf('.'); + if (dot <= 0) return 'folder'; + return null; +} + +function isPng(name: string): boolean { + return name.toLowerCase().endsWith('.png'); +} + +function sortEntries(items: Entry[], sort: SortKey): Entry[] { + const folders = items.filter((e) => e.kind === 'folder'); + const images = items.filter((e) => e.kind === 'image'); + const byName = (a: Entry, b: Entry) => + a.name.localeCompare(b.name, undefined, { numeric: true }); + folders.sort(byName); + if (sort === 'name') images.sort(byName); + else if (sort === 'date_asc') images.sort(byName); + else images.sort((a, b) => byName(b, a)); + return folders.concat(images); +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function BrowserScreen({ + onPickFile, + onOpenSettings, + onOpenCapture, + onClose, + busy, + onStitchReady, +}: { + onPickFile: (entry: Entry) => void; + onOpenSettings: () => void; + onOpenCapture: () => void; + onClose: () => void; + busy: boolean; + onStitchReady?: (session: StitchSession) => void; +}): React.JSX.Element { + const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); + const [entries, setEntries] = useState([]); + const [sort, setSort] = useState('date_desc'); + const [columns, setColumns] = useState(DEFAULT_COLUMNS); + const [status, setStatus] = useState('starting…'); + const [streamCfg, setStreamCfg] = useState(DEFAULT_STREAM_CONFIG); + const connStatus = useConnStatus(baseUrl(streamCfg)); + useEffect(() => { + loadStreamConfig().then(setStreamCfg).catch(() => {}); + }, []); + + const load = useCallback(async (dir: string) => { + setStatus(`listing ${dir}`); + try { + let exists = false; + try { + exists = await FileUtils.exists(dir); + } catch (e: any) { + setStatus(`exists() threw: ${e?.message ?? e}`); + return; + } + if (!exists) { + if (dir === DEFAULT_DIR) { + try { + await FileUtils.makeDir(dir); + } catch (e: any) { + setStatus(`makeDir() threw: ${e?.message ?? e}`); + return; + } + } else { + setStatus(`directory missing: ${dir}`); + setEntries([]); + return; + } + } + + let raw: any = null; + try { + raw = await FileUtils.listFiles(dir); + } catch (e: any) { + setStatus(`listFiles() threw: ${e?.message ?? e}`); + return; + } + + const list: any[] = Array.isArray(raw) ? raw : []; + const out: Entry[] = []; + for (const entry of list) { + const path = typeof entry === 'string' ? entry : entry?.path; + const sdkType = typeof entry === 'string' ? undefined : entry?.type; + if (!path) continue; + const name = basename(path); + if (!name || name.startsWith('.')) continue; + let kind: EntryKind | null; + if (sdkType === 0) kind = 'folder'; + else if (sdkType === 1) kind = isPng(name) || classifyEntry(name) === 'image' ? 'image' : null; + else kind = classifyEntry(name); + if (!kind) continue; + out.push({ name, path, kind }); + } + setEntries(out); + const imgs = out.filter((e) => e.kind === 'image').length; + const dirs = out.filter((e) => e.kind === 'folder').length; + setStatus(`${imgs} image${imgs === 1 ? '' : 's'}, ${dirs} folder${dirs === 1 ? '' : 's'}`); + } catch (err: any) { + setStatus(`unexpected: ${err?.message ?? err}`); + } + }, []); + + useEffect(() => { + load(currentDir); + }, [currentDir, load]); + + // Note: previous versions probed FileUtils.getExternalDirPath() to expose + // SD-card roots. The new Manta firmware leaves getCurrentActivity() null + // when the JS bundle first runs, so that native call throws an NPE that + // bypasses our try/catch and tears the RN bridge down. Use "Browse…" + // (system picker) for SD / WebDAV instead. + const currentRoot = INTERNAL_ROOT; + + const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); + + // Stitch-pick state: when the user opens Tools → Stitch we flip into + // pick mode. First tap on an image becomes image 1; second tap becomes + // image 2 and immediately opens the StitchEditor. + const [stitchPick, setStitchPick] = useState<{ first: StitchImage | null } | null>(null); + + const measureImage = useCallback((path: string): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + Image.getSize('file://' + path, (w, h) => resolve({ width: w, height: h }), (e) => reject(e)); + }); + }, []); + + const onPickEntry = useCallback( + async (entry: Entry) => { + if (entry.kind === 'folder') { + setCurrentDir(entry.path); + return; + } + if (stitchPick && onStitchReady) { + // Resolve dimensions before constructing the session — the + // editor needs them up front to lay out the preview. + try { + const dim = await measureImage(entry.path); + const img: StitchImage = { + path: entry.path, width: dim.width, height: dim.height, + crop: { cropTop: 0, cropBottom: 0, cropLeft: 0, cropRight: 0 }, + }; + if (!stitchPick.first) { + setStitchPick({ first: img }); + setStatus(`Stitch · picked "${entry.name}" — tap a second image`); + } else { + const session = makeSession(stitchPick.first, img); + setStitchPick(null); + setStatus('Opening stitch editor…'); + onStitchReady(session); + } + } catch (e: any) { + setStatus(`stitch pick failed: ${e?.message ?? e}`); + } + return; + } + onPickFile(entry); + }, + [onPickFile, stitchPick, onStitchReady, measureImage], + ); + + const onSystemPicker = useCallback(async () => { + try { + setStatus('opening system file picker…'); + const res: any = await RattaFileSelector.selectFile({ + selectType: 1, + suffixList: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'], + maxNum: 1, + title: 'Pick image', + }); + const path: string | undefined = Array.isArray(res) ? res[0] : res; + if (!path) { + setStatus('picker cancelled'); + return; + } + const name = basename(path); + onPickFile({ name, path, kind: 'image' }); + } catch (e: any) { + setStatus(`picker failed: ${e?.message ?? e}`); + } + }, [onPickFile]); + + const navigateUp = useCallback(() => { + if (currentDir === currentRoot) return; + const p = parentDir(currentDir); + if (p === currentRoot || p.startsWith(currentRoot + '/')) { + setCurrentDir(p); + } else { + setCurrentDir(currentRoot); + } + }, [currentDir, currentRoot]); + + const goHome = useCallback(() => setCurrentDir(DEFAULT_DIR), []); + const canGoUp = currentDir !== currentRoot; + + return ( + + { + onClose(); + PluginManager.closePluginView().catch(() => {}); + }} + /> + load(currentDir) }, + { separator: true, label: '' }, + { label: 'Exit', onPress: () => { onClose(); PluginManager.closePluginView().catch(() => {}); } }, + ], + }, + { + label: 'View', + items: SORT_OPTIONS.map((opt) => ({ + label: `Sort by ${opt.label}${opt.key === sort ? ' •' : ''}`, + onPress: () => setSort(opt.key), + })).concat([ + { label: '', separator: true } as any, + { label: 'Bigger thumbnails', onPress: () => setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS)) } as any, + { label: 'Smaller thumbnails', onPress: () => setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS)) } as any, + ]), + }, + { + label: 'Tools', + items: [ + { label: 'Live Capture…', onPress: onOpenCapture }, + { separator: true, label: '' }, + { + label: stitchPick ? 'Cancel stitch pick' : 'Stitch two images…', + onPress: () => { + if (stitchPick) { + setStitchPick(null); + setStatus('Stitch pick cancelled.'); + } else if (onStitchReady) { + setStitchPick({ first: null }); + setStatus('Stitch · tap an image to pick the first'); + } + }, + disabled: !onStitchReady, + }, + { separator: true, label: '' }, + { label: 'Settings…', onPress: onOpenSettings }, + ], + }, + ]} + /> + + Up + Home + setCurrentDir(INTERNAL_ROOT)}>Internal + + Live… + + + + + {currentDir} + + + + + {sorted.length === 0 ? ( + + Nothing here. + {currentDir} + + File → Open from system… for WebDAV/SD. Tools → Live Capture… to stream from a Mac. + + + ) : ( + it.path} + numColumns={columns} + contentContainerStyle={styles.grid} + renderItem={({ item }) => ( + onPickEntry(item)} + disabled={busy} + > + {item.kind === 'folder' ? ( + + [DIR] + + ) : ( + + )} + {item.name} + + )} + /> + )} + + + {status} + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + toolbar: { + flexDirection: 'row', alignItems: 'center', gap: 4, + paddingHorizontal: 6, paddingVertical: 4, + backgroundColor: theme.bg, + borderBottomWidth: 1, borderBottomColor: theme.shadow, + }, + pathInsetWrap: { paddingHorizontal: 6, paddingVertical: 4, backgroundColor: theme.bg }, + pathInset: { paddingHorizontal: 6, paddingVertical: 4 }, + pathTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + listInset: { flex: 1, margin: 6 }, + grid: { padding: 6 }, + tile: { padding: 4, alignItems: 'center' }, + thumb: { + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + folderThumb: { + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + alignItems: 'center', justifyContent: 'center', + }, + folderIcon: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + tileName: { marginTop: 4, fontFamily: 'VT323', fontSize: 14, color: theme.text, textAlign: 'center' }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + empty: { fontFamily: 'VT323', fontSize: 18, color: theme.text, marginBottom: 4 }, + emptySub: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center', marginBottom: 12 }, + emptyHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx new file mode 100644 index 0000000..f240cef --- /dev/null +++ b/src/screens/Capture.tsx @@ -0,0 +1,461 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + ToastAndroid, + View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { AdjustmentPanel } from '../AdjustmentPanel'; +import { StatusDot } from '../StatusDot'; +import { downloadAndBake, lanHttp, lanJson, lanPostFile } from '../imageProcessor'; +import { insertAndTrack, replaceInPlace } from '../embedTracker'; +import { baseUrl, saveStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { MenuBar, StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { useConnStatus } from '../useConnStatus'; +import { + Adjustments, + DEFAULT_ADJUSTMENTS, + EmbedTrack, + LogEntry, + StreamConfig, +} from '../types'; + +// During live capture the Mac server bakes adjustments into each frame +// before sending, so the Supernote-side bake should be identity. This +// trips the native module's fast path (no per-pixel loop). +const IDENTITY_ADJUSTMENTS: Adjustments = DEFAULT_ADJUSTMENTS; + +const PREVIEW_MAX_DIM = 800; +const MIN_INTERVAL_SEC = 0.2; +const MAX_INTERVAL_SEC = 60; +const MAX_LOG_LINES = 12; +const INTERVAL_STEP = 0.5; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function nowHHMMSS(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function CaptureScreen({ + config, + onConfigChange, + onBack, + onOpenSettings, + onOpenSourcePicker, +}: { + config: StreamConfig; + onConfigChange: (cfg: StreamConfig) => void; + onBack: () => void; + onOpenSettings: () => void; + onOpenSourcePicker: () => void; +}): React.JSX.Element { + const [adjustments, setAdjustments] = useState(DEFAULT_ADJUSTMENTS); + const [capturing, setCapturing] = useState(false); + const [framePath, setFramePath] = useState(null); + const [lastFrameTs, setLastFrameTs] = useState(0); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + const [track, setTrack] = useState(null); + const [intervalSec, setIntervalSec] = useState(config.intervalSec); + const [resolutionMul, setResolutionMul] = useState(config.resolutionMul); + const inFlight = useRef(false); + const connStatus = useConnStatus(baseUrl(config)); + + const pushLog = useCallback((msg: string) => { + setLogs((prev) => { + const next = prev.concat([{ ts: Date.now(), msg }]); + return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next; + }); + }, []); + + const url = baseUrl(config); + + // Debounced push of adjustments to the Mac server. The server applies + // them to each captured frame; the Supernote-side bake then runs an + // identity pass and skips the per-pixel loop. + useEffect(() => { + if (!url) return; + const timer = setTimeout(() => { + lanHttp('POST', `${url}/adjust`, adjustments, 2000).catch((e: any) => { + pushLog(`adjust push failed: ${e?.message ?? e}`); + }); + }, 250); + return () => clearTimeout(timer); + }, [adjustments, url, pushLog]); + + const fetchOnce = useCallback(async (): Promise => { + if (!url) { + pushLog('no server configured — open Settings'); + return null; + } + if (inFlight.current) return null; + inFlight.current = true; + try { + const path = await downloadAndBake( + `${url}/frame`, + IDENTITY_ADJUSTMENTS, + PREVIEW_MAX_DIM, + Math.max(2000, Math.round(intervalSec * 1500)), + ); + setFramePath(path); + setLastFrameTs(Date.now()); + return path; + } catch (e: any) { + pushLog(`fetch failed: ${e?.message ?? e}`); + return null; + } finally { + inFlight.current = false; + } + }, [url, intervalSec, pushLog]); + + // Capture polling loop. + useEffect(() => { + if (!capturing) return; + let alive = true; + let timer: ReturnType | null = null; + const tick = async () => { + if (!alive) return; + await fetchOnce(); + if (!alive) return; + timer = setTimeout(tick, Math.max(MIN_INTERVAL_SEC, intervalSec) * 1000); + }; + pushLog(`start @ ${intervalSec}s — ${url || '(no server)'}`); + tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + pushLog('pause'); + }; + }, [capturing, intervalSec, fetchOnce, pushLog, url]); + + // Re-bake the most recent raw frame when adjustments change while paused. + // (When running, the next tick naturally picks up new adjustments.) + // Skipped to keep the implementation simple — adjustments take effect on + // the next captured frame. + + const onTogglePolling = useCallback(() => { + setCapturing((c) => !c); + }, []); + + const onCheckStatus = useCallback(async () => { + if (!url) { + pushLog('no server configured'); + return; + } + try { + const j: any = await lanJson('GET', `${url}/status`, undefined, 3000); + pushLog(`status: running=${j.running}, src=${j.source}, ip=${j.ip ?? '?'}`); + } catch (e: any) { + pushLog(`status failed: ${e?.message ?? e}`); + } + }, [url, pushLog]); + + const changeInterval = useCallback( + (delta: number) => { + const next = clamp( + Math.round((intervalSec + delta) * 10) / 10, + MIN_INTERVAL_SEC, + MAX_INTERVAL_SEC, + ); + if (next === intervalSec) return; + setIntervalSec(next); + const cfg = { ...config, intervalSec: next }; + onConfigChange(cfg); + saveStreamConfig(cfg).catch(() => {}); + }, + [intervalSec, config, onConfigChange], + ); + + const changeResolutionMul = useCallback( + (delta: number) => { + const next = Math.round(clamp(resolutionMul + delta, 0.1, 1.0) * 10) / 10; + if (next === resolutionMul) return; + setResolutionMul(next); + const cfg = { ...config, resolutionMul: next }; + onConfigChange(cfg); + saveStreamConfig(cfg).catch(() => {}); + if (url) { + lanHttp('POST', `${url}/resolution`, { mul: next }, 2000).catch((e: any) => + pushLog(`resolution push failed: ${e?.message ?? e}`), + ); + } + }, + [resolutionMul, config, onConfigChange, url, pushLog], + ); + + // Send the current resolution to the server when we first connect or + // when the URL changes. Also send when adjustments are pushed but the + // server hasn't been told yet. + useEffect(() => { + if (!url) return; + lanHttp('POST', `${url}/resolution`, { mul: resolutionMul }, 2000).catch(() => {}); + // intentionally only on URL change — interactive changes go through + // changeResolutionMul above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url]); + + const onRemoveBg = useCallback(async () => { + if (busy || !framePath) return; + if (capturing) { + pushLog('pause first — Remove BG only works on a still frame'); + return; + } + setBusy(true); + pushLog('removing background via BiRefNet…'); + try { + const outPath = await lanPostFile(`${url}/birefnet?bg=white`, framePath, 'image/png', 120000); + setFramePath(outPath); + setLastFrameTs(Date.now()); + pushLog('background removed (still frame). Insert or Replace to embed.'); + } catch (e: any) { + pushLog(`Remove BG failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, framePath, capturing, url, pushLog]); + + const onInsert = useCallback(async () => { + if (busy) return; + setBusy(true); + pushLog('insert: baking full-res…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); + const newTrack = await insertAndTrack(fullPath); + if (newTrack) { + setTrack(newTrack); + pushLog(`insert ok — num=${newTrack.numInPage}`); + } else { + pushLog('insert ok — couldn’t resolve element (Replace disabled)'); + } + try { + ToastAndroid.showWithGravity('Inserted frame', ToastAndroid.SHORT, ToastAndroid.BOTTOM); + } catch {} + } catch (e: any) { + pushLog(`insert failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, url, pushLog]); + + const onReplace = useCallback(async () => { + if (busy || !track) return; + setBusy(true); + pushLog('replace: baking full-res…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); + const newTrack = await replaceInPlace(track, fullPath); + if (newTrack) { + setTrack(newTrack); + pushLog(`replace ok — num=${newTrack.numInPage}`); + } else { + pushLog('replace done — tracker reset'); + setTrack(null); + } + } catch (e: any) { + pushLog(`replace failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, track, url, pushLog]); + + // "Back" returns to the file browser (existing flow). + const onBackToBrowser = useCallback(() => { + setCapturing(false); + onBack(); + }, [onBack]); + + // "Close" exits the plugin entirely, back to the note canvas — the user + // asked for this so they can resume drawing without going via Browser. + const onCloseToCanvas = useCallback(() => { + setCapturing(false); + PluginManager.closePluginView().catch(() => {}); + }, []); + + // "Replace & Close": one-tap refresh + back to canvas. Falls back to + // Insert if there's no tracked embed yet. + const onReplaceAndClose = useCallback(async () => { + if (busy) return; + setBusy(true); + pushLog(track ? 'replace+close: baking…' : 'insert+close: baking…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); + if (track) { + await replaceInPlace(track, fullPath); + } else { + await insertAndTrack(fullPath); + } + setCapturing(false); + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + pushLog(`failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [busy, track, url, pushLog]); + + const sourceUri = framePath ? 'file://' + framePath : null; + const ageSec = lastFrameTs ? Math.round((Date.now() - lastFrameTs) / 1000) : null; + + return ( + + + fetchOnce(), disabled: !url }, + { separator: true, label: '' }, + { label: 'Check status', onPress: onCheckStatus, disabled: !url }, + { label: 'Close (back to canvas)', onPress: onCloseToCanvas }, + { label: 'Back to Browser', onPress: onBackToBrowser }, + ], + }, + { + label: 'Source', + items: [ + { label: 'Pick screen / window / region…', onPress: onOpenSourcePicker, disabled: !url }, + ], + }, + { + label: 'Image', + items: [ + { label: 'Remove background (still only)', onPress: onRemoveBg, disabled: !framePath || capturing }, + { separator: true, label: '' }, + { label: 'Settings…', onPress: onOpenSettings }, + ], + }, + ]} + /> + + + + {url || '(no server set)'} · {intervalSec.toFixed(1)}s · {Math.round(resolutionMul * 100)}% ·{' '} + {lastFrameTs ? `frame ${ageSec}s ago` : 'no frame yet'}{track ? ' · EMBED' : ''} + + + + + + {sourceUri ? ( + + ) : ( + + {capturing ? 'waiting for first frame…' : 'STREAM → Start to begin'} + + )} + + + + + {capturing ? 'Pause' : 'Start'} + + fetchOnce()} disabled={busy || !url}>Pull + Rm BG + + Interval + changeInterval(-INTERVAL_STEP)} disabled={busy}>− + {intervalSec.toFixed(1)}s + changeInterval(+INTERVAL_STEP)} disabled={busy}>+ + + + + Source… + Status + + Res + changeResolutionMul(-0.1)} disabled={busy}>− + {Math.round(resolutionMul * 100)}% + changeResolutionMul(+0.1)} disabled={busy}>+ + + + + + + + {logs.length === 0 ? ( + C:\>_ logs will appear here… + ) : ( + logs.map((e, i) => ( + + {nowHHMMSS()} {e.msg} + + )) + )} + + + + + Close + + Insert + Replace + + {track ? 'Replace & Close' : 'Insert & Close'} + + + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + statusStrip: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: theme.bg, + paddingHorizontal: 8, paddingVertical: 2, + }, + statusStripTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + previewArea: { + flex: 1, margin: 6, + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + }, + previewImg: { width: '100%', height: '100%' }, + placeholder: { fontFamily: 'VT323', fontSize: 18, color: theme.textMuted, padding: 16 }, + controlBar: { + flexDirection: 'row', alignItems: 'center', gap: 4, + paddingHorizontal: 6, paddingVertical: 3, + backgroundColor: theme.bg, + }, + dim: { fontFamily: 'VT323', fontSize: 14, color: theme.text, marginHorizontal: 4 }, + fieldVal: { + fontFamily: 'VT323', fontSize: 14, color: theme.text, + minWidth: 44, textAlign: 'center', + }, + logBox: { + height: 100, marginHorizontal: 6, marginVertical: 4, + paddingHorizontal: 4, + }, + logScroll: { paddingVertical: 4 }, + logEmpty: { fontFamily: 'VT323', fontSize: 14, color: theme.shadow }, + logLine: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + backgroundColor: theme.bg, alignItems: 'center', + }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/DropInbox.tsx b/src/screens/DropInbox.tsx new file mode 100644 index 0000000..a916aff --- /dev/null +++ b/src/screens/DropInbox.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { insertAndTrack } from '../embedTracker'; +import { downloadAndBake, lanHttp, lanJson } from '../imageProcessor'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { DEFAULT_ADJUSTMENTS, DEFAULT_STREAM_CONFIG } from '../types'; + +// "Drop Inbox" — files dropped (or AirDropped) into ~/EmbedImage/Drop on +// the Mac appear here, newest first. Tap to insert; the Mac then moves +// the file out of the watch folder so it won't show up again. + +type DropItem = { name: string; size: number; mtime: number }; + +export function DropInbox({ onClose }: { onClose: () => void }): React.JSX.Element { + const [url, setUrl] = useState(''); + const [items, setItems] = useState([]); + const [folder, setFolder] = useState(''); + const [status, setStatus] = useState('loading…'); + const [busy, setBusy] = useState(false); + const [thumbs, setThumbs] = useState>({}); + + // Used by the prefetch loop to bail when the user navigates away or + // hits Refresh again — we don't want an old run's setState to clobber + // a newer one with stale results. + const refreshGen = useRef(0); + + const refresh = useCallback(async (u: string) => { + if (!u) return; + const myGen = ++refreshGen.current; + setStatus('Refreshing…'); + let list: DropItem[] = []; + try { + const res: any = await lanJson('GET', `${u}/drop/list`, undefined, 5000); + if (refreshGen.current !== myGen) return; + list = Array.isArray(res?.items) ? res.items : []; + setItems(list); + setFolder(String(res?.folder ?? '')); + setStatus(`${list.length} item${list.length === 1 ? '' : 's'}`); + } catch (e: any) { + if (refreshGen.current !== myGen) return; + setStatus(`List failed: ${e?.message ?? e}`); + return; + } + // Pre-fetch small thumbnails for the visible list. Each fetch has + // its own short timeout; a single dead file can't stall the row. + const newThumbs: Record = {}; + for (const it of list.slice(0, 24)) { + if (refreshGen.current !== myGen) return; + try { + const path = await downloadAndBake( + `${u}/drop/file?name=${encodeURIComponent(it.name)}`, + DEFAULT_ADJUSTMENTS, + 200, + 5000, + ); + if (refreshGen.current !== myGen) return; + newThumbs[it.name] = 'file://' + path; + setThumbs((prev) => ({ ...prev, [it.name]: 'file://' + path })); + } catch { + // skip; tile shows the placeholder. + } + } + }, []); + + useEffect(() => { + let alive = true; + (async () => { + try { + const cfg = await loadStreamConfig(); + if (!alive) return; + const u = baseUrl(cfg); + setUrl(u); + if (u) refresh(u); + else setStatus('no server configured'); + } catch (e: any) { + if (!alive) return; + setStatus(`init failed: ${e?.message ?? e}`); + } + })(); + return () => { + alive = false; + refreshGen.current++; // invalidate any in-flight refresh + }; + }, [refresh]); + + const onInsert = useCallback(async (it: DropItem) => { + if (busy || !url) return; + setBusy(true); + setStatus(`embedding ${it.name}…`); + try { + const path = await downloadAndBake( + `${url}/drop/file?name=${encodeURIComponent(it.name)}`, + DEFAULT_ADJUSTMENTS, + 0, + 15000, + ); + await insertAndTrack(path); + // Consume on the Mac (moves it to .consumed/). Best-effort; if it + // fails the file just shows up again on next Refresh which is + // harmless. + await lanHttp('POST', `${url}/drop/consume`, { name: it.name }, 3000).catch(() => {}); + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + setStatus(`Insert failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [busy, url]); + + return ( + + + + {folder || '(folder unknown)'} + refresh(url)} disabled={busy || !url}>Refresh + + + + {items.length === 0 ? ( + + Inbox is empty. + + Drop or AirDrop image files into:{'\n'}{folder || '~/EmbedImage/Drop'}{'\n\n'} + They'll show up here on the next Refresh. + + + ) : ( + it.name} + numColumns={3} + contentContainerStyle={styles.grid} + renderItem={({ item }) => { + const uri = thumbs[item.name]; + return ( + onInsert(item)} disabled={busy}> + + {uri ? ( + + ) : ( + [IMG] + )} + + {item.name} + + ); + }} + /> + )} + + + {status} + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + subhead: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 6, paddingVertical: 4, + backgroundColor: theme.bg, + }, + folder: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + listInset: { flex: 1, margin: 6 }, + grid: { padding: 6 }, + tile: { width: '33.333%', padding: 4 }, + tileImgWrap: { + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + alignItems: 'center', justifyContent: 'center', + }, + tileImg: { width: '100%', height: '100%' }, + tilePlaceholder: { fontFamily: 'VT323', fontSize: 14, color: theme.shadow }, + tileName: { marginTop: 4, fontFamily: 'VT323', fontSize: 14, color: theme.text, textAlign: 'center' }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + emptyTitle: { fontFamily: 'VT323', fontSize: 18, color: theme.text, marginBottom: 6 }, + emptyHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/LayerStitch.tsx b/src/screens/LayerStitch.tsx new file mode 100644 index 0000000..af2639f --- /dev/null +++ b/src/screens/LayerStitch.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, Image, Pressable, SafeAreaView, ScrollView, + StyleSheet, Text, View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { lanPostFile } from '../imageProcessor'; +import { + composeLassoLayers, insertComposed, LassoSurvey, surveyLasso, +} from '../layerStitch'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// "Stitch Layers" — lasso some content on the canvas, tap this button, +// pick which Supernote note layers (0..3) to include, choose transparent +// or white background, then insert/send the composited image. + +export function LayerStitch({ onClose }: { onClose: () => void }): React.JSX.Element { + const [survey, setSurvey] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [transparent, setTransparent] = useState(true); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState('Reading lasso…'); + const [previewUri, setPreviewUri] = useState(null); + + useEffect(() => { + (async () => { + try { + const s = await surveyLasso(); + if (!s) { + setError('No lasso selection found. Lasso some content first.'); + setStatus('Idle'); + return; + } + setSurvey(s); + // Default selection: every layer that has at least one lassoed element. + const def = new Set(s.layers.filter((l) => l.count > 0).map((l) => l.layerId)); + setSelected(def); + setStatus(`Lasso ok · ${s.layers.length} layer${s.layers.length === 1 ? '' : 's'}`); + } catch (e: any) { + FileLogger.log('LayerStitch', 'survey threw', e?.message ?? String(e)); + setError(e?.message ?? String(e)); + } + })(); + }, []); + + const toggle = useCallback((layerId: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(layerId)) next.delete(layerId); else next.add(layerId); + return next; + }); + }, []); + + const totalPicked = survey + ? survey.layers.filter((l) => selected.has(l.layerId)).reduce((n, l) => n + l.count, 0) + : 0; + + const onPreview = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing preview…'); + try { + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + setPreviewUri('file://' + path + '?t=' + Date.now()); + setStatus(`Preview ready (${path.split('/').pop()})`); + } catch (e: any) { + FileLogger.log('LayerStitch', 'preview threw', e?.message ?? String(e)); + setStatus(`Preview failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + const onInsert = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing & inserting…'); + try { + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + await insertComposed(path); + setStatus('Inserted. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 800); + } catch (e: any) { + FileLogger.log('LayerStitch', 'insert threw', e?.message ?? String(e)); + setStatus(`Insert failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + const onSendToMac = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing & sending to Mac…'); + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + setStatus('No Mac server set. Settings → Mac Capture Server.'); + setBusy(false); + return; + } + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + const name = `manta_layers_${new Date().toISOString().replace(/[:.]/g, '-')}.${cfg.lassoFormat}`; + await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}&format=${cfg.lassoFormat}`, path, 'image/png', 30000); + setStatus(`Sent to Mac as ${name}. Closing…`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + } catch (e: any) { + FileLogger.log('LayerStitch', 'send threw', e?.message ?? String(e)); + setStatus(`Send failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + if (error) { + return ( + + + + + No selection. + {error} + OK + + + + ); + } + + return ( + + + + + Pick layers to include + {!survey ? ( + + + {status} + + ) : ( + + {survey.layers.length === 0 ? ( + No layers found on this page. + ) : ( + survey.layers.map((l) => { + const isSelected = selected.has(l.layerId); + const hasContent = l.count > 0; + return ( + toggle(l.layerId)} + disabled={busy} + > + + {isSelected ? '☑' : '☐'} + + + {l.name}{l.isCurrent ? ' (current)' : ''} + + + + {l.count} item{l.count === 1 ? '' : 's'} + + + ); + }) + )} + + )} + + + Background: + setTransparent(true)} disabled={busy} + >Transparent + setTransparent(false)} disabled={busy} + >White + + + {previewUri ? ( + + + + ) : null} + + + + Cancel + + Preview + To Mac + Insert + + + {status} · {totalPicked} item{totalPicked === 1 ? '' : 's'} picked + + {busy && ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + body: { flex: 1 }, + bodyInner: { padding: 8, gap: 8 }, + section: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + loading: { padding: 12, flexDirection: 'row', alignItems: 'center', gap: 8 }, + dim: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, + layerList: { padding: 4 }, + layerRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 8, paddingVertical: 6, + }, + layerRowMuted: { opacity: 0.5 }, + checkbox: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' }, + checkboxMark: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + layerName: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + layerCount: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + bgRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4, + }, + bgLabel: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + previewBox: { padding: 4, minHeight: 160, alignItems: 'center', justifyContent: 'center' }, + previewImg: { width: '100%', height: 200, backgroundColor: '#fff' }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, + }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + msg: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Preview.tsx b/src/screens/Preview.tsx new file mode 100644 index 0000000..c16b9b1 --- /dev/null +++ b/src/screens/Preview.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + ToastAndroid, + View, +} from 'react-native'; +import { PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { AdjustmentPanel } from '../AdjustmentPanel'; +import { StatusDot } from '../StatusDot'; +import { adjustmentsAreDefault, bakeFile, lanPostFile } from '../imageProcessor'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { useConnStatus } from '../useConnStatus'; +import { Adjustments, DEFAULT_ADJUSTMENTS, DEFAULT_STREAM_CONFIG, Entry, StreamConfig } from '../types'; + +const PREVIEW_MAX_DIM = 800; + +function isPng(name: string): boolean { + return name.toLowerCase().endsWith('.png'); +} + +export function PreviewScreen({ + entry, + onBack, +}: { + entry: Entry; + onBack: () => void; +}): React.JSX.Element { + const [adjustments, setAdjustments] = useState(DEFAULT_ADJUSTMENTS); + const [previewPath, setPreviewPath] = useState(null); + const [previewing, setPreviewing] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(entry.name); + const [bgRemovedPath, setBgRemovedPath] = useState(null); + const [streamCfg, setStreamCfg] = useState(DEFAULT_STREAM_CONFIG); + const connStatus = useConnStatus(baseUrl(streamCfg)); + + useEffect(() => { + loadStreamConfig().then(setStreamCfg).catch(() => {}); + }, []); + + // Debounced preview bake. + useEffect(() => { + const allDefault = adjustmentsAreDefault(adjustments); + if (allDefault && isPng(entry.name)) { + setPreviewPath(null); + return; + } + let cancelled = false; + const t = setTimeout(() => { + setPreviewing(true); + bakeFile(entry.path, adjustments, PREVIEW_MAX_DIM) + .then((p) => { + if (!cancelled) setPreviewPath(p); + }) + .catch((e: any) => { + if (!cancelled) setStatus(`preview failed: ${e?.message ?? e}`); + }) + .finally(() => { + if (!cancelled) setPreviewing(false); + }); + }, 350); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [entry, adjustments]); + + const onInsert = useCallback(async () => { + if (busy) return; + setBusy(true); + setStatus(`embedding ${entry.name}…`); + try { + const allDefault = adjustmentsAreDefault(adjustments); + const sourcePath = bgRemovedPath ?? entry.path; + // Skip bake if no adjustments and the source is already a PNG (the + // BiRefNet output is PNG too, so this branch covers that as well). + const needsBake = !allDefault || (!bgRemovedPath && !isPng(entry.name)); + let pathToInsert = sourcePath; + if (needsBake) { + try { + pathToInsert = await bakeFile(sourcePath, adjustments, 0); + } catch (e: any) { + setStatus(`process failed: ${e?.message ?? e}`); + return; + } + } + const res: any = await PluginNoteAPI.insertImage(pathToInsert); + if (!res || res.success === false) { + setStatus(`insert failed: ${res?.error?.message ?? 'unknown'}`); + return; + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + try { + ToastAndroid.showWithGravity(`Embedded ${entry.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); + } catch {} + PluginManager.closePluginView().catch(() => {}); + } catch (err: any) { + setStatus(`insert threw: ${err?.message ?? err}`); + } finally { + setBusy(false); + } + }, [entry, adjustments, busy, bgRemovedPath]); + + const onRemoveBg = useCallback(async () => { + if (busy) return; + const url = baseUrl(streamCfg); + if (!url) { + setStatus('no Mac server configured — set it in Settings'); + return; + } + setBusy(true); + setStatus('removing background via BiRefNet (Mac side)…'); + try { + const outPath = await lanPostFile(`${url}/birefnet?bg=white`, entry.path, 'image/png', 120000); + setBgRemovedPath(outPath); + setPreviewPath(outPath); + setStatus('background removed. Insert to embed.'); + } catch (e: any) { + setStatus(`Remove BG failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, streamCfg, entry.path]); + + const sourceUri = 'file://' + (previewPath ?? bgRemovedPath ?? entry.path); + + return ( + + + + {status} + {previewing ? : null} + + + + + + + + + + + Cancel + + Remove BG + Insert + + + {baseUrl(streamCfg) || 'no Mac server set'} + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + statusStrip: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 8, paddingVertical: 2, + backgroundColor: theme.bg, + }, + statusTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + previewArea: { + flex: 1, margin: 6, + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + }, + previewImg: { width: '100%', height: '100%' }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, + }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/RecognizeLasso.tsx b/src/screens/RecognizeLasso.tsx new file mode 100644 index 0000000..74c2b82 --- /dev/null +++ b/src/screens/RecognizeLasso.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { Animated, Easing, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { recognizeLasso } from '../lassoRecognize'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// Inkling's killer feature, blended in: lasso some handwriting, tap +// "Recognize" in the lasso menu, it OCRs the strokes and drops typed +// text into the note. Headless-ish flow like Refresh. + +const PROGRESS_SEGMENTS = 16; + +export function RecognizeLasso(): React.JSX.Element { + const [status, setStatus] = useState('Recognizing lasso…'); + const tick = React.useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.timing(tick, { + toValue: PROGRESS_SEGMENTS, duration: 1600, + easing: Easing.linear, useNativeDriver: false, + }), + ).start(); + }, [tick]); + + useEffect(() => { + let cancelled = false; + const WATCHDOG_MS = 25000; + const watchdog = setTimeout(() => { + if (cancelled) return; + cancelled = true; + setStatus('Timed out. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + }, WATCHDOG_MS); + + (async () => { + try { + FileLogger.log('Recognize', 'screen mounted'); + setStatus('Reading lasso content…'); + const res = await recognizeLasso(); + FileLogger.log('Recognize', 'result', { text: res.text, stats: res.stats, hasRect: !!res.insertRect }); + if (cancelled) return; + + if (!res.text) { + setStatus( + res.stats.strokes === 0 + ? 'Nothing to recognize.\nLasso some handwriting first.' + : 'OCR returned empty. Try a clearer hand.', + ); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2800); + return; + } + + setStatus(`Inserting (${res.text.length} chars)…`); + const rect = res.insertRect ?? { left: 200, top: 200, right: 1800, bottom: 290 }; + const ins: any = await PluginNoteAPI.insertText({ + textContentFull: res.text, + textRect: rect, + fontSize: 36, + textAlign: 0, + textBold: 0, + textItalics: 0, + textFrameWidthType: 0, + textFrameStyle: 0, + textEditable: 1, + } as any); + FileLogger.log('Recognize', 'insertText ->', ins); + if (!ins || ins.success === false) { + setStatus(`Insert failed: ${ins?.error?.message ?? 'unknown'}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); + return; + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + if (cancelled) return; + setStatus(`Recognized ${res.text.length} chars`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 800); + } catch (e: any) { + FileLogger.log('Recognize', 'threw', e?.message ?? String(e)); + if (cancelled) return; + setStatus(`Failed: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2800); + } + })(); + return () => { + cancelled = true; + clearTimeout(watchdog); + }; + }, []); + + const segIndex = tick.interpolate({ + inputRange: [0, PROGRESS_SEGMENTS], outputRange: [0, PROGRESS_SEGMENTS], + }); + + return ( + + + + + Recognizing handwriting… + + + {Array.from({ length: PROGRESS_SEGMENTS }).map((_, i) => ( + + ))} + + + {status} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + progressInset: { padding: 4 }, + progressRow: { flexDirection: 'row', gap: 2 }, + segment: { flex: 1, height: 16, backgroundColor: theme.titleBg }, + msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, +}); diff --git a/src/screens/Refresh.tsx b/src/screens/Refresh.tsx new file mode 100644 index 0000000..f70dfd0 --- /dev/null +++ b/src/screens/Refresh.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, Easing, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { replaceInPlace, insertAndTrack } from '../embedTracker'; +import { downloadAndBake } from '../imageProcessor'; +import { baseUrl, loadEmbedTrack, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { DEFAULT_ADJUSTMENTS } from '../types'; + +const PROGRESS_SEGMENTS = 16; + +export function RefreshScreen(): React.JSX.Element { + const [status, setStatus] = useState('Refreshing embed…'); + const tick = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.timing(tick, { + toValue: PROGRESS_SEGMENTS, + duration: 1600, + easing: Easing.linear, + useNativeDriver: false, + }), + ).start(); + }, [tick]); + + useEffect(() => { + let cancelled = false; + + // Hard ceiling on the whole flow. Without this, a single hung SDK + // call (insertAndTrack / replaceInPlace can stall on a bad note + // context) leaves the spinner running forever. + const WATCHDOG_MS = 15000; + const watchdog = setTimeout(() => { + if (cancelled) return; + cancelled = true; + setStatus('Timed out. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + }, WATCHDOG_MS); + + (async () => { + try { + setStatus('Loading config…'); + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + setStatus('No server configured.\nOpen Embed Image → Settings.'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + return; + } + if (cancelled) return; + setStatus('Fetching frame from Mac…'); + const track = await loadEmbedTrack(); + const fullPath = await downloadAndBake(`${url}/frame`, DEFAULT_ADJUSTMENTS, 0, 8000); + if (cancelled) return; + setStatus(track ? 'Replacing embed…' : 'Inserting…'); + if (track) { + await replaceInPlace(track, fullPath); + } else { + await insertAndTrack(fullPath); + } + if (cancelled) return; + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + if (cancelled) return; + setStatus(`FAILED: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + } + })(); + return () => { + cancelled = true; + clearTimeout(watchdog); + }; + }, []); + + const segIndex = tick.interpolate({ + inputRange: [0, PROGRESS_SEGMENTS], + outputRange: [0, PROGRESS_SEGMENTS], + }); + + return ( + + + + + Refreshing embed… + + + {Array.from({ length: PROGRESS_SEGMENTS }).map((_, i) => ( + + ))} + + + {status} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + progressInset: { padding: 4 }, + progressRow: { flexDirection: 'row', gap: 2 }, + segment: { flex: 1, height: 16, backgroundColor: theme.titleBg }, + msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, +}); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx new file mode 100644 index 0000000..fb39762 --- /dev/null +++ b/src/screens/Settings.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { baseUrl, loadStreamConfig, saveStreamConfig } from '../storage'; +import { lanJson } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { DEFAULT_STREAM_CONFIG, LassoFormat, StreamConfig } from '../types'; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function SettingsScreen({ + onBack, + onSaved, +}: { + onBack: () => void; + onSaved: (cfg: StreamConfig) => void; +}): React.JSX.Element { + const [host, setHost] = useState(DEFAULT_STREAM_CONFIG.host); + const [port, setPort] = useState(String(DEFAULT_STREAM_CONFIG.port)); + const [intervalSec, setIntervalSec] = useState(String(DEFAULT_STREAM_CONFIG.intervalSec)); + const [resolutionMul, setResolutionMul] = useState(String(DEFAULT_STREAM_CONFIG.resolutionMul)); + const [lassoFormat, setLassoFormat] = useState(DEFAULT_STREAM_CONFIG.lassoFormat); + const [status, setStatus] = useState('loading…'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + (async () => { + const cfg = await loadStreamConfig(); + setHost(cfg.host); + setPort(String(cfg.port)); + setIntervalSec(String(cfg.intervalSec)); + setResolutionMul(String(cfg.resolutionMul)); + setLassoFormat(cfg.lassoFormat); + setStatus(cfg.host ? `loaded: ${baseUrl(cfg)}` : 'no server configured'); + })(); + }, []); + + const onTest = useCallback(async () => { + setStatus('testing…'); + try { + const portNum = clamp(parseInt(port, 10) || 0, 1, 65535); + const url = `http://${host.trim()}:${portNum}/status`; + const json: any = await lanJson('GET', url, undefined, 4000); + setStatus(`ok — running=${json.running}, source=${json.source}, ip=${json.ip ?? '?'}`); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } + }, [host, port]); + + const onSave = useCallback(async () => { + setBusy(true); + try { + const portNum = clamp(parseInt(port, 10) || DEFAULT_STREAM_CONFIG.port, 1, 65535); + const intervalNum = clamp(parseFloat(intervalSec) || DEFAULT_STREAM_CONFIG.intervalSec, 0.2, 60); + const mulNum = clamp( + parseFloat(resolutionMul) || DEFAULT_STREAM_CONFIG.resolutionMul, + 0.1, + 1.0, + ); + const cfg: StreamConfig = { + host: host.trim(), + port: portNum, + intervalSec: intervalNum, + resolutionMul: Math.round(mulNum * 10) / 10, + lassoFormat, + }; + await saveStreamConfig(cfg); + setStatus(`saved: ${baseUrl(cfg)}`); + onSaved(cfg); + } catch (e: any) { + setStatus(`save failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [host, port, intervalSec, resolutionMul, lassoFormat, onSaved]); + + return ( + + + + + Mac Capture Server + + IP shown by the Mac app + its port. Plugin fetches frames from + http://<host>:<port>/frame. + + + + + + + + + + + + + 0.2 .. 60. 1.0 = 1 fps (good for e-ink). + + + + + + 0.1 .. 1.0. Mac downscales each frame by this factor before sending. + + + + + Lasso → Mac format + + setLassoFormat('png')} + > + PNG + + setLassoFormat('jpg')} + > + JPEG + + + + File format the Mac will save your "Send to Mac" exports as. + PNG keeps transparency; JPEG is smaller. + + + + + Test connection + + Save + + + + + {status} + + {busy ? ( + + + + ) : null} + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element { + return ( + + {label} + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + body: { padding: 12, gap: 10 }, + section: { fontFamily: 'VT323', fontSize: 20, color: theme.text, marginBottom: 2 }, + hint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, + field: { gap: 4 }, + label: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + inputWrap: { padding: 0 }, + input: { + fontFamily: 'VT323', + paddingHorizontal: 8, paddingVertical: 6, + fontSize: 16, color: theme.text, + }, + row: { flexDirection: 'row', gap: 8, marginTop: 8, alignItems: 'center' }, + formatRow: { flexDirection: 'row', gap: 6, marginTop: 4 }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/SourcePicker.tsx b/src/screens/SourcePicker.tsx new file mode 100644 index 0000000..05c0afd --- /dev/null +++ b/src/screens/SourcePicker.tsx @@ -0,0 +1,418 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Easing, + FlatList, + GestureResponderEvent, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { lanHttp, lanJson } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { MacWindow, Region } from '../types'; + +type Mode = 'menu' | 'window' | 'region'; + +export function SourcePicker({ + baseUrl, + onClose, + onChanged, +}: { + baseUrl: string; + onClose: () => void; + onChanged: (label: string) => void; +}): React.JSX.Element { + const [mode, setMode] = useState('menu'); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [windows, setWindows] = useState([]); + const [shotUri, setShotUri] = useState(null); + const [shotInfo, setShotInfo] = useState<{ + monLeft: number; monTop: number; monW: number; monH: number; scale: number; + previewW: number; previewH: number; + } | null>(null); + const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null); + const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null); + + const postSource = useCallback(async (payload: object, label: string) => { + setBusy(true); + try { + await lanHttp('POST', `${baseUrl}/source`, payload, 3000); + onChanged(label); + onClose(); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl, onChanged, onClose]); + + const onPickScreen = useCallback(() => { + postSource({ source: 'screen', monitor_index: 1 }, 'Full screen'); + }, [postSource]); + + const openWindowList = useCallback(async () => { + setMode('window'); + setBusy(true); + setStatus('loading windows…'); + try { + const list: any = await lanJson('GET', `${baseUrl}/windows`, undefined, 3000); + setWindows(Array.isArray(list) ? list : []); + setStatus(`${Array.isArray(list) ? list.length : 0} windows`); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl]); + + const openRegion = useCallback(async () => { + setMode('region'); + setBusy(true); + setStatus('TUNING… grabbing screen from Mac'); + try { + const { downloadAndBake } = await import('../imageProcessor'); + const { DEFAULT_ADJUSTMENTS } = await import('../types'); + const path = await downloadAndBake( + `${baseUrl}/preview-shot?max=700`, + DEFAULT_ADJUSTMENTS, + 0, + 8000, + ); + const st: any = await lanJson('GET', `${baseUrl}/status`, undefined, 3000); + const mon = st?.monitor ?? { left: 0, top: 0, width: 0, height: 0 }; + setShotUri('file://' + path); + Image.getSize('file://' + path, (pw, ph) => { + const scale = mon.width ? pw / mon.width : 1.0; + setShotInfo({ + monLeft: mon.left || 0, + monTop: mon.top || 0, + monW: mon.width || pw, + monH: mon.height || ph, + scale: scale > 0 ? scale : 1.0, + previewW: pw, + previewH: ph, + }); + setStatus(`SIGNAL OK — ${mon.width}×${mon.height}`); + }, () => setStatus('NO SIGNAL')); + } catch (e: any) { + setStatus(`FAULT: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl]); + + const previewLayout = useMemo(() => { + if (!shotInfo) return null; + return { width: shotInfo.previewW, height: shotInfo.previewH }; + }, [shotInfo]); + + const onTouchStart = useCallback((e: GestureResponderEvent) => { + const { locationX, locationY } = e.nativeEvent; + setDragStart({ x: locationX, y: locationY }); + setDragEnd({ x: locationX, y: locationY }); + }, []); + const onTouchMove = useCallback((e: GestureResponderEvent) => { + const { locationX, locationY } = e.nativeEvent; + setDragEnd({ x: locationX, y: locationY }); + }, []); + const onTouchEnd = useCallback(() => {}, []); + + const selectionRect = useMemo(() => { + if (!dragStart || !dragEnd) return null; + const x = Math.min(dragStart.x, dragEnd.x); + const y = Math.min(dragStart.y, dragEnd.y); + const w = Math.abs(dragEnd.x - dragStart.x); + const h = Math.abs(dragEnd.y - dragStart.y); + if (w < 8 || h < 8) return null; + return { x, y, w, h }; + }, [dragStart, dragEnd]); + + const onUseRegion = useCallback(() => { + if (!selectionRect || !shotInfo) return; + const invScale = 1.0 / shotInfo.scale; + const region: Region = { + x: Math.round(shotInfo.monLeft + selectionRect.x * invScale), + y: Math.round(shotInfo.monTop + selectionRect.y * invScale), + w: Math.round(selectionRect.w * invScale), + h: Math.round(selectionRect.h * invScale), + }; + postSource({ source: 'region', region }, `Region ${region.w}×${region.h}`); + }, [selectionRect, shotInfo, postSource]); + + // ---- Menu mode (Win95 chrome) ---- + if (mode === 'menu') { + return ( + + + + + Capture Source + Choose what the Mac sends. + + Full Screen + + Window… + + Region (drag on CRT) + + + {status || 'Ready.'} + {busy ? : null} + + ); + } + + if (mode === 'window') { + return ( + + setMode('menu')} /> + + String(w.id)} + renderItem={({ item }) => ( + [styles.windowRow, pressed && styles.windowRowPressed]} + onPress={() => postSource({ source: 'window', window_id: item.id }, `${item.owner}`)} + disabled={busy} + > + {item.owner || '(unknown)'} + + {item.title || '(no title)'} · {item.w}×{item.h} + + + )} + /> + + {status} + {busy ? : null} + + ); + } + + // ---- Region mode: CRT screensaver aesthetic ---- + return ( + + setMode('menu')} /> + + + {shotUri && previewLayout ? ( + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onTouchStart} + onResponderMove={onTouchMove} + onResponderRelease={onTouchEnd} + > + + + + {selectionRect ? ( + + + {Math.round(selectionRect.w / (shotInfo?.scale ?? 1))}× + {Math.round(selectionRect.h / (shotInfo?.scale ?? 1))} + + + ) : null} + SOURCE-1 + {status} + + ) : ( + + + NO SIGNAL + {status} + + )} + + + + { setDragStart(null); setDragEnd(null); }}>Clear + + + Use Selection + + + {status} + {busy ? : null} + + ); +} + +// ---- CRT cosmetic components ---- + +function CrtBezel({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( + + + {children} + + + + + + + + + ); +} + +function Scanlines(): React.JSX.Element { + // Horizontal scanline overlay — done with a column of thin transparent + // strips. Looks like an old CRT under e-ink rendering. + const rows = 80; + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + ); +} + +function CrtPhosphorGlow(): React.JSX.Element { + const flicker = React.useRef(new Animated.Value(0.85)).current; + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(flicker, { toValue: 1.0, duration: 200, easing: Easing.linear, useNativeDriver: false }), + Animated.timing(flicker, { toValue: 0.85, duration: 240, easing: Easing.linear, useNativeDriver: false }), + Animated.timing(flicker, { toValue: 0.95, duration: 90, easing: Easing.linear, useNativeDriver: false }), + ]), + ).start(); + }, [flicker]); + return ; +} + +function CrtCornerTag({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} + +function CrtBottomTag({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} + +function Overlay(): React.JSX.Element { + return ; +} + +const PHOSPHOR = '#5fff7f'; + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + body: { flex: 1, padding: 16, alignItems: 'center' }, + menuCard: { padding: 16, minWidth: 280 }, + menuTitle: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + menuHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, + windowList: { flex: 1, margin: 6 }, + windowRow: { paddingVertical: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: theme.shadow }, + windowRowPressed: { backgroundColor: theme.selBg }, + windowOwner: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + windowTitle: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, marginTop: 2 }, + + // ---- CRT styles ---- + crtWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 8, backgroundColor: theme.bg }, + crtOuter: { alignItems: 'center' }, + crtFrame: { + padding: 18, + backgroundColor: '#dcdcc8', // beige '90s monitor plastic + borderTopLeftRadius: 14, borderTopRightRadius: 14, + borderBottomLeftRadius: 6, borderBottomRightRadius: 6, + borderTopWidth: 2, borderLeftWidth: 2, + borderRightWidth: 2, borderBottomWidth: 2, + borderTopColor: '#fff', borderLeftColor: '#fff', + borderRightColor: '#808070', borderBottomColor: '#808070', + }, + crtInner: { + padding: 6, + backgroundColor: '#080808', + borderRadius: 8, + borderWidth: 2, borderColor: '#202020', + }, + crtScreen: { backgroundColor: '#000', borderRadius: 4, overflow: 'hidden' }, + crtImage: { opacity: 0.85 }, + scanline: { + position: 'absolute', left: 0, right: 0, height: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + glow: { + ...StyleSheet.absoluteFillObject, + backgroundColor: PHOSPHOR, + }, + selRect: { + position: 'absolute', + borderWidth: 2, borderColor: PHOSPHOR, + backgroundColor: 'rgba(95,255,127,0.18)', + }, + selRectLabel: { + position: 'absolute', top: 2, left: 4, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, + }, + crtCornerTag: { + position: 'absolute', top: 4, right: 6, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, + }, + crtBottomTag: { + position: 'absolute', bottom: 4, left: 8, right: 8, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 12, + }, + crtBlank: { + width: 320, height: 200, backgroundColor: '#000', + alignItems: 'center', justifyContent: 'center', gap: 8, + }, + crtBlankTxt: { color: PHOSPHOR, fontFamily: 'VT323', fontSize: 22 }, + crtBlankSub: { color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, opacity: 0.7 }, + crtStandRow: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, + marginTop: -2, + paddingHorizontal: 20, paddingVertical: 6, + backgroundColor: '#dcdcc8', + borderBottomLeftRadius: 16, borderBottomRightRadius: 16, + borderTopWidth: 1, borderTopColor: '#808070', + }, + crtStand: { width: 60, height: 4, backgroundColor: '#808070', borderRadius: 2 }, + crtKnob: { + width: 12, height: 12, borderRadius: 6, + backgroundColor: '#a0a090', + borderTopWidth: 1, borderLeftWidth: 1, + borderTopColor: '#fff', borderLeftColor: '#fff', + }, + crtLed: { + width: 6, height: 6, borderRadius: 3, + backgroundColor: PHOSPHOR, + }, + + controlRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + backgroundColor: theme.bg, alignItems: 'center', + }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/StitchEditor.tsx b/src/screens/StitchEditor.tsx new file mode 100644 index 0000000..a235920 --- /dev/null +++ b/src/screens/StitchEditor.tsx @@ -0,0 +1,559 @@ +/* eslint-disable react-native/no-inline-styles */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + GestureResponderEvent, + Image, + PanResponder, + PanResponderGestureState, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { PluginNoteAPI } from 'sn-plugin-lib'; +import { composeStitch, ImageCrop, StitchImage, StitchParams } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// Ported from Laumss/Inkling's StitchEditor. Gesture math is verbatim +// (it's the meat); chrome is replaced with Win95 styling and the actual +// compositing runs through our ImageProcessor.composeStitch native call +// instead of the Inkling-private path. + +const HEADER_H = 32; // our TitleBar is tighter than Inkling's 56 +const CTRL_H = 110; +const PAD = 12; +const HANDLE_LEN = 40; +const HANDLE_THICK = 6; +const HIT_RADIUS = 40; +const MIN_VISIBLE = 0.05; + +type DragEdge = { + kind: 'edge'; + imageIndex: number; + cropKey: keyof ImageCrop; + oppositeKey: keyof ImageCrop; + startVal: number; + sign: number; + pxPerUnit: number; +}; +type DragBoth = { + kind: 'both'; + cropKey: keyof ImageCrop; + oppositeKey: keyof ImageCrop; + startVal0: number; + startVal1: number; + sign: number; + pxPerUnit0: number; + pxPerUnit1: number; +}; +type DragState = DragEdge | DragBoth; + +type Layout = { + scale: number; + dispW: number; + dispH: number; + originX: number; + originY: number; + rects: Array<{ x: number; y: number; w: number; h: number }>; +}; + +const DEFAULT_PARAMS: StitchParams = { direction: 'vertical', overlap: 100, topLayerIndex: 1 }; +const DEFAULT_CROP: ImageCrop = { cropTop: 0, cropBottom: 0, cropLeft: 0, cropRight: 0 }; + +export type StitchSession = { + images: [StitchImage, StitchImage]; + params: StitchParams; +}; + +export function makeSession(a: StitchImage, b: StitchImage, override: Partial = {}): StitchSession { + return { + images: [ + { ...a, crop: { ...DEFAULT_CROP, ...(a.crop ?? {}) } }, + { ...b, crop: { ...DEFAULT_CROP, ...(b.crop ?? {}) } }, + ], + params: { ...DEFAULT_PARAMS, ...override }, + }; +} + +export function StitchEditor({ + session: initial, + onCancel, + onInserted, +}: { + session: StitchSession; + onCancel: () => void; + onInserted: () => void; +}): React.JSX.Element { + const screen = Dimensions.get('window'); + const previewH = screen.height - HEADER_H - CTRL_H - 60; // 60 = our action row + + const [images, setImages] = useState(() => + initial.images.map((img) => ({ ...img, crop: { ...img.crop } })), + ); + const [params, setParams] = useState(() => ({ ...initial.params })); + const [busy, setBusy] = useState(false); + + const imagesRef = useRef(images); + const paramsRef = useRef(params); + useEffect(() => { imagesRef.current = images; }, [images]); + useEffect(() => { paramsRef.current = params; }, [params]); + + const layout: Layout | null = useMemo(() => { + if (images.length < 2) return null; + const dir = params.direction; + const eff = images.map((img) => ({ + w: img.width * (1 - img.crop.cropLeft - img.crop.cropRight), + h: img.height * (1 - img.crop.cropTop - img.crop.cropBottom), + })); + let totalW: number, totalH: number; + if (dir === 'vertical') { + totalW = Math.max(eff[0].w, eff[1].w); + totalH = eff[0].h + eff[1].h - params.overlap; + } else { + totalW = eff[0].w + eff[1].w - params.overlap; + totalH = Math.max(eff[0].h, eff[1].h); + } + const availW = screen.width - PAD * 2; + const availH = previewH - PAD * 2; + const scale = Math.min(availW / Math.max(totalW, 1), availH / Math.max(totalH, 1), 1); + const dispW = totalW * scale; + const dispH = totalH * scale; + const originX = (screen.width - dispW) / 2; + const originY = HEADER_H + (previewH - dispH) / 2; + const rects = [ + { x: 0, y: 0, w: eff[0].w * scale, h: eff[0].h * scale }, + { x: 0, y: 0, w: eff[1].w * scale, h: eff[1].h * scale }, + ]; + if (dir === 'vertical') { + rects[0].x = originX; rects[0].y = originY; + rects[1].x = originX; rects[1].y = originY + eff[0].h * scale - params.overlap * scale; + } else { + rects[0].x = originX; rects[0].y = originY; + rects[1].x = originX + eff[0].w * scale - params.overlap * scale; rects[1].y = originY; + } + return { scale, dispW, dispH, originX, originY, rects }; + }, [images, params, screen, previewH]); + + const layoutRef = useRef(layout); + useEffect(() => { layoutRef.current = layout; }, [layout]); + + const dragRef = useRef(null); + const overlapStartRef = useRef(0); + const dragKindRef = useRef<'edge' | 'both' | 'overlap' | null>(null); + + // Gesture handler — ported verbatim from Inkling. + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !busy, + onMoveShouldSetPanResponder: () => !busy, + onPanResponderGrant: (evt: GestureResponderEvent) => { + if (busy) return; + const { pageX, pageY } = evt.nativeEvent; + const lo = layoutRef.current; + const imgs = imagesRef.current; + const p = paramsRef.current; + if (!lo || imgs.length < 2) return; + + const isVert = p.direction === 'vertical'; + const r0 = lo.rects[0]; + const r1 = lo.rects[1]; + const juncY = isVert ? (r0.y + r0.h + r1.y) / 2 : (r0.y + r0.h / 2); + const juncX = isVert ? (r0.x + r0.w / 2) : (r0.x + r0.w + r1.x) / 2; + + type H = { x: number; y: number; type: 'outerAxis' | 'overlap' | 'perpShared'; + imgIdx?: number; hitEdge?: string; edge?: string }; + const allHandles: H[] = isVert ? [ + { x: r0.x + r0.w / 2, y: r0.y, type: 'outerAxis', imgIdx: 0, hitEdge: 'top' }, + { x: r1.x + r1.w / 2, y: r1.y + r1.h, type: 'outerAxis', imgIdx: 1, hitEdge: 'bottom' }, + { x: juncX, y: juncY, type: 'overlap' }, + { x: lo.originX, y: juncY, type: 'perpShared', edge: 'left' }, + { x: lo.originX + lo.dispW, y: juncY, type: 'perpShared', edge: 'right' }, + ] : [ + { x: r0.x, y: r0.y + r0.h / 2, type: 'outerAxis', imgIdx: 0, hitEdge: 'left' }, + { x: r1.x + r1.w, y: r1.y + r1.h / 2, type: 'outerAxis', imgIdx: 1, hitEdge: 'right' }, + { x: juncX, y: juncY, type: 'overlap' }, + { x: juncX, y: lo.originY, type: 'perpShared', edge: 'top' }, + { x: juncX, y: lo.originY + lo.dispH, type: 'perpShared', edge: 'bottom' }, + ]; + + let bestDist = HIT_RADIUS; + let bestH: H | null = null; + for (const h of allHandles) { + const d = Math.hypot(pageX - h.x, pageY - h.y); + if (d < bestDist) { bestDist = d; bestH = h; } + } + + if (!bestH) { + overlapStartRef.current = p.overlap; + dragKindRef.current = 'overlap'; + dragRef.current = null; + return; + } + + if (bestH.type === 'overlap') { + overlapStartRef.current = p.overlap; + dragKindRef.current = 'overlap'; + dragRef.current = null; + } else if (bestH.type === 'outerAxis') { + const idx = bestH.imgIdx!; + const hitEdge = bestH.hitEdge! as 'top' | 'bottom' | 'left' | 'right'; + const targetEdge = + hitEdge === 'top' ? 'bottom' : + hitEdge === 'bottom' ? 'top' : + hitEdge === 'left' ? 'right' : 'left'; + const cropKey = `crop${cap(targetEdge)}` as keyof ImageCrop; + const oppositeKey = `crop${cap(hitEdge)}` as keyof ImageCrop; + const isVertAxis = hitEdge === 'top' || hitEdge === 'bottom'; + const sign = (hitEdge === 'top' || hitEdge === 'left') ? 1 : -1; + const imgPixelSize = isVertAxis ? imgs[idx].height : imgs[idx].width; + dragRef.current = { + kind: 'edge', + imageIndex: idx, + cropKey, oppositeKey, + startVal: imgs[idx].crop[cropKey], + sign, + pxPerUnit: imgPixelSize * lo.scale, + }; + dragKindRef.current = 'edge'; + } else { + const edge = bestH.edge! as 'top' | 'bottom' | 'left' | 'right'; + const cropKey = `crop${cap(edge)}` as keyof ImageCrop; + const oppositeEdge = + edge === 'top' ? 'bottom' : + edge === 'bottom' ? 'top' : + edge === 'left' ? 'right' : 'left'; + const oppositeKey = `crop${cap(oppositeEdge)}` as keyof ImageCrop; + const isVertAxis = edge === 'top' || edge === 'bottom'; + const sign = (edge === 'top' || edge === 'left') ? 1 : -1; + dragRef.current = { + kind: 'both', + cropKey, oppositeKey, + startVal0: imgs[0].crop[cropKey], + startVal1: imgs[1].crop[cropKey], + sign, + pxPerUnit0: (isVertAxis ? imgs[0].height : imgs[0].width) * lo.scale, + pxPerUnit1: (isVertAxis ? imgs[1].height : imgs[1].width) * lo.scale, + }; + dragKindRef.current = 'both'; + } + }, + + onPanResponderMove: (_: GestureResponderEvent, g: PanResponderGestureState) => { + if (busy) return; + if (dragKindRef.current === 'edge' && dragRef.current?.kind === 'edge') { + const d = dragRef.current; + const img = imagesRef.current[d.imageIndex]; + if (!img) return; + const isVertAxis = d.cropKey === 'cropTop' || d.cropKey === 'cropBottom'; + const screenDelta = isVertAxis ? g.dy : g.dx; + const cropDelta = (d.sign * screenDelta) / d.pxPerUnit; + const maxVal = 1 - MIN_VISIBLE - img.crop[d.oppositeKey]; + const newVal = Math.max(0, Math.min(maxVal, d.startVal + cropDelta)); + setImages((prev) => { + const next = [...prev]; + next[d.imageIndex] = { + ...next[d.imageIndex], + crop: { ...next[d.imageIndex].crop, [d.cropKey]: newVal }, + }; + return next; + }); + } else if (dragKindRef.current === 'both' && dragRef.current?.kind === 'both') { + const d = dragRef.current; + const imgs = imagesRef.current; + const isVertAxis = d.cropKey === 'cropTop' || d.cropKey === 'cropBottom'; + const screenDelta = isVertAxis ? g.dy : g.dx; + const delta0 = (d.sign * screenDelta) / d.pxPerUnit0; + const delta1 = (d.sign * screenDelta) / d.pxPerUnit1; + const max0 = 1 - MIN_VISIBLE - imgs[0].crop[d.oppositeKey]; + const max1 = 1 - MIN_VISIBLE - imgs[1].crop[d.oppositeKey]; + const v0 = Math.max(0, Math.min(max0, d.startVal0 + delta0)); + const v1 = Math.max(0, Math.min(max1, d.startVal1 + delta1)); + setImages((prev) => { + const next = [...prev]; + next[0] = { ...next[0], crop: { ...next[0].crop, [d.cropKey]: v0 } }; + next[1] = { ...next[1], crop: { ...next[1].crop, [d.cropKey]: v1 } }; + return next; + }); + } else if (dragKindRef.current === 'overlap') { + const lo = layoutRef.current; + const p = paramsRef.current; + const imgs = imagesRef.current; + if (!lo || imgs.length < 2) return; + const isVert = p.direction === 'vertical'; + const screenDelta = isVert ? -g.dy : -g.dx; + const imgDelta = screenDelta / lo.scale; + const dim0 = isVert + ? imgs[0].height * (1 - imgs[0].crop.cropTop - imgs[0].crop.cropBottom) + : imgs[0].width * (1 - imgs[0].crop.cropLeft - imgs[0].crop.cropRight); + const dim1 = isVert + ? imgs[1].height * (1 - imgs[1].crop.cropTop - imgs[1].crop.cropBottom) + : imgs[1].width * (1 - imgs[1].crop.cropLeft - imgs[1].crop.cropRight); + const maxOvl = Math.min(dim0, dim1) * 0.8; + const newOvl = Math.max(0, Math.min(maxOvl, overlapStartRef.current + imgDelta)); + setParams((prev) => ({ ...prev, overlap: Math.round(newOvl) })); + } + }, + onPanResponderRelease: () => { dragRef.current = null; dragKindRef.current = null; }, + onPanResponderTerminate: () => { dragRef.current = null; dragKindRef.current = null; }, + }), + ).current; + + const toggleDirection = useCallback(() => { + setParams((p) => ({ + ...p, + direction: p.direction === 'vertical' ? 'horizontal' : 'vertical', + overlap: Math.round(p.overlap * 0.5), + })); + }, []); + const swapOrder = useCallback(() => setImages((prev) => [prev[1], prev[0]]), []); + const toggleTopLayer = useCallback(() => { + setParams((p) => ({ ...p, topLayerIndex: p.topLayerIndex === 0 ? 1 : 0 })); + }, []); + const adjustOverlap = useCallback((delta: number) => { + setParams((p) => { + const imgs = imagesRef.current; + if (imgs.length < 2) return p; + const dim0 = p.direction === 'vertical' + ? imgs[0].height * (1 - imgs[0].crop.cropTop - imgs[0].crop.cropBottom) + : imgs[0].width * (1 - imgs[0].crop.cropLeft - imgs[0].crop.cropRight); + const dim1 = p.direction === 'vertical' + ? imgs[1].height * (1 - imgs[1].crop.cropTop - imgs[1].crop.cropBottom) + : imgs[1].width * (1 - imgs[1].crop.cropLeft - imgs[1].crop.cropRight); + const maxOvl = Math.min(dim0, dim1) * 0.8; + return { ...p, overlap: Math.max(0, Math.min(Math.round(maxOvl), p.overlap + delta)) }; + }); + }, []); + + const confirmCompose = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const outPath = + `/data/user/0/com.ratta.supernote.pluginhost/cache/stitch_${Date.now()}.png`; + FileLogger.raw('[embedimage] stitch compose start', { + a: images[0].path, b: images[1].path, params, + }); + await composeStitch(images[0], images[1], params, outPath); + FileLogger.raw('[embedimage] stitch compose ok ->', outPath); + const res: any = await PluginNoteAPI.insertImage(outPath); + FileLogger.raw('[embedimage] stitch insertImage ->', JSON.stringify(res)); + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + onInserted(); + } catch (e: any) { + FileLogger.raw('[embedimage] stitch failed:', e?.message ?? e); + setBusy(false); + } + }, [busy, images, params, onInserted]); + + if (!layout) { + return ( + + + + Need two images to stitch. + + + ); + } + + const isVert = params.direction === 'vertical'; + const drawOrder = params.topLayerIndex === 0 ? [1, 0] : [0, 1]; + + const handles = computeHandles(layout, isVert); + + return ( + + + + + + + {drawOrder.map((idx) => { + const r = layout.rects[idx]; + const img = images[idx]; + const fullW = img.width * layout.scale; + const fullH = img.height * layout.scale; + const offsetL = img.crop.cropLeft * img.width * layout.scale; + const offsetT = img.crop.cropTop * img.height * layout.scale; + return ( + + + + ); + })} + + {params.overlap > 0 && ( + + )} + + {[0, 1].map((idx) => { + const r = layout.rects[idx]; + return ( + + + + {idx + 1} + + + ); + })} + + {handles.map((h, i) => ( + + ))} + + + + + + {isVert ? '↕ Vertical' : '↔ Horizontal'} + + ⇅ Swap + + ☰ Top: {params.topLayerIndex + 1} + + + + Overlap: {params.overlap}px + adjustOverlap(-50)} disabled={busy}>−50 + adjustOverlap(-10)} disabled={busy}>−10 + adjustOverlap(10)} disabled={busy}>+10 + adjustOverlap(50)} disabled={busy}>+50 + + + + + Cancel + + Stitch & Insert + + + {busy && ( + + + Compositing… + + )} + + ); +} + +function cap(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } + +function computeHandles(layout: Layout, isVert: boolean) { + const r0 = layout.rects[0]; + const r1 = layout.rects[1]; + const juncY = isVert ? (r0.y + r0.h + r1.y) / 2 : (r0.y + r0.h / 2); + const juncX = isVert ? (r0.x + r0.w / 2) : (r0.x + r0.w + r1.x) / 2; + if (isVert) { + return [ + { x: r0.x + r0.w / 2, y: r0.y, horiz: true, type: 'outerAxis' as const }, + { x: r1.x + r1.w / 2, y: r1.y + r1.h, horiz: true, type: 'outerAxis' as const }, + { x: juncX, y: juncY, horiz: true, type: 'overlap' as const }, + { x: layout.originX, y: juncY, horiz: false, type: 'perpShared' as const }, + { x: layout.originX + layout.dispW, y: juncY, horiz: false, type: 'perpShared' as const }, + ]; + } + return [ + { x: r0.x, y: r0.y + r0.h / 2, horiz: false, type: 'outerAxis' as const }, + { x: r1.x + r1.w, y: r1.y + r1.h / 2, horiz: false, type: 'outerAxis' as const }, + { x: juncX, y: juncY, horiz: false, type: 'overlap' as const }, + { x: juncX, y: layout.originY, horiz: true, type: 'perpShared' as const }, + { x: juncX, y: layout.originY + layout.dispH, horiz: true, type: 'perpShared' as const }, + ]; +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + emptyTxt: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + preview: { position: 'relative', backgroundColor: theme.bg }, + compositeBorder: { position: 'absolute', borderWidth: 1, borderColor: theme.shadow }, + imgBorder: { position: 'absolute', borderWidth: 1, borderStyle: 'dashed' }, + imgLabel: { position: 'absolute', backgroundColor: theme.titleBg, paddingHorizontal: 6, paddingVertical: 2 }, + imgLabelTxt: { color: theme.titleFg, fontSize: 13, fontFamily: 'VT323' }, + overlapZone: { + position: 'absolute', + backgroundColor: 'rgba(0,0,0,0.12)', + borderWidth: 1, borderColor: 'rgba(0,0,0,0.25)', borderStyle: 'dotted', + }, + handleBar: { position: 'absolute', backgroundColor: theme.text, borderRadius: 3 }, + handleH: { width: HANDLE_LEN, height: HANDLE_THICK }, + handleV: { width: HANDLE_THICK, height: HANDLE_LEN }, + handleOverlap: { backgroundColor: theme.shadow }, + controlPanel: { padding: 6, gap: 6 }, + controlRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }, + overlapLabel: { fontFamily: 'VT323', fontSize: 15, color: theme.text, minWidth: 110 }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(192,192,192,0.7)', + alignItems: 'center', justifyContent: 'center', gap: 12, + }, + busyTxt: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, +}); diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..1ed1a82 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,57 @@ +import { ImageProcessor } from './imageProcessor'; +import { DEFAULT_STREAM_CONFIG, EmbedTrack, StreamConfig } from './types'; + +const STREAM_CONFIG_KEY = 'streamConfig'; +const EMBED_TRACK_KEY = 'embedTrack'; + +export async function loadStreamConfig(): Promise { + if (!ImageProcessor?.getConfigValue) return DEFAULT_STREAM_CONFIG; + try { + const raw = await ImageProcessor.getConfigValue(STREAM_CONFIG_KEY); + if (!raw) return DEFAULT_STREAM_CONFIG; + const parsed = JSON.parse(raw); + const fmt = parsed.lassoFormat === 'jpg' ? 'jpg' : 'png'; + return { + host: typeof parsed.host === 'string' ? parsed.host : '', + port: typeof parsed.port === 'number' ? parsed.port : DEFAULT_STREAM_CONFIG.port, + intervalSec: + typeof parsed.intervalSec === 'number' ? parsed.intervalSec : DEFAULT_STREAM_CONFIG.intervalSec, + resolutionMul: + typeof parsed.resolutionMul === 'number' + ? Math.max(0.1, Math.min(1.0, parsed.resolutionMul)) + : DEFAULT_STREAM_CONFIG.resolutionMul, + lassoFormat: fmt as 'png' | 'jpg', + }; + } catch { + return DEFAULT_STREAM_CONFIG; + } +} + +export async function saveStreamConfig(cfg: StreamConfig): Promise { + if (!ImageProcessor?.setConfigValue) return; + await ImageProcessor.setConfigValue(STREAM_CONFIG_KEY, JSON.stringify(cfg)); +} + +export function baseUrl(cfg: StreamConfig): string { + const host = cfg.host.trim(); + if (!host) return ''; + return `http://${host}:${cfg.port}`; +} + +// Persisted across JS-runtime tear-downs so the "Refresh Embed" sidebar +// button can find the last-inserted picture element on a fresh launch. +export async function saveEmbedTrack(track: EmbedTrack | null): Promise { + if (!ImageProcessor?.setConfigValue) return; + await ImageProcessor.setConfigValue(EMBED_TRACK_KEY, track ? JSON.stringify(track) : null); +} + +export async function loadEmbedTrack(): Promise { + if (!ImageProcessor?.getConfigValue) return null; + try { + const raw = await ImageProcessor.getConfigValue(EMBED_TRACK_KEY); + if (!raw) return null; + return JSON.parse(raw) as EmbedTrack; + } catch { + return null; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2a94d95 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,82 @@ +export type SortKey = 'date_desc' | 'date_asc' | 'name'; +export type EntryKind = 'image' | 'folder'; +export type Entry = { name: string; path: string; kind: EntryKind }; +export type Screen = + | 'browser' + | 'preview' + | 'settings' + | 'capture' + | 'refresh' + | 'sourcepicker' + | 'dropinbox' + | 'recognizelasso' + | 'stitch' + | 'layerstitch'; + +export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; + +export const DITHER_LABELS: Record = { + none: 'Off', + fs1: '1-bit', + fs4: '4-gray', + atkinson: 'Atkinson', +}; + +export type Adjustments = { + fade: number; + brightness: number; + contrast: number; + gamma: number; + dither: DitherMode; +}; + +export const DEFAULT_ADJUSTMENTS: Adjustments = { + fade: 0, + brightness: 0, + contrast: 0, + gamma: 1.0, + dither: 'none', +}; + +export type Preset = Adjustments & { name: string }; + +export type LassoFormat = 'png' | 'jpg'; + +export type StreamConfig = { + host: string; + port: number; + intervalSec: number; + resolutionMul: number; // 0.1 .. 1.0, server downscales each frame + lassoFormat: LassoFormat; // file format used by Send to Mac (lasso) +}; + +export const DEFAULT_STREAM_CONFIG: StreamConfig = { + host: '', + port: 9000, + intervalSec: 1.0, + resolutionMul: 1.0, + lassoFormat: 'png', +}; + +export type SourceKind = 'screen' | 'window' | 'region'; +export type MacWindow = { + id: number; + owner: string; + title: string; + x: number; + y: number; + w: number; + h: number; +}; +export type Region = { x: number; y: number; w: number; h: number }; + +export type EmbedTrack = { + notePath: string; + page: number; + layerNum: number; + numInPage: number; + uuid: string; + rect: { left: number; top: number; right: number; bottom: number }; +}; + +export type LogEntry = { ts: number; msg: string }; diff --git a/src/ui/Win95.tsx b/src/ui/Win95.tsx new file mode 100644 index 0000000..2ad5a97 --- /dev/null +++ b/src/ui/Win95.tsx @@ -0,0 +1,296 @@ +import React, { ReactNode, useRef, useState } from 'react'; +import { + GestureResponderEvent, + Modal, + Pressable, + PressableProps, + StyleSheet, + Text, + TextProps, + View, + ViewProps, + ViewStyle, +} from 'react-native'; +import { insetEdges, outsetEdges, PIXEL_FONT, theme } from './theme'; + +// ---- Frames ---- + +export function Win95Frame({ children, style, ...rest }: ViewProps): React.JSX.Element { + return ( + + {children} + + ); +} + +export function Win95InsetPanel({ children, style, ...rest }: ViewProps): React.JSX.Element { + return ( + + {children} + + ); +} + +// ---- Button ---- + +export function Win95Button(props: PressableProps & { + children?: ReactNode; + active?: boolean; + primary?: boolean; + small?: boolean; +}): React.JSX.Element { + const { children, active, primary, small, style, ...rest } = props; + const [pressed, setPressed] = useState(false); + const looksDown = pressed || active; + + return ( + { setPressed(true); rest.onPressIn?.(e); }} + onPressOut={(e) => { setPressed(false); rest.onPressOut?.(e); }} + style={({ pressed: p }) => [ + styles.btnOuter, + looksDown || p ? insetEdges() : outsetEdges(), + small ? styles.btnSmall : null, + typeof style === 'function' ? (style as any)({ pressed: p }) : style, + ]} + > + + {typeof children === 'string' ? ( + {children} + ) : children} + + + ); +} + +// ---- Title bar (Win95 window header with X close button) ---- + +export function TitleBar({ + title, + onClose, +}: { + title: string; + onClose?: () => void; +}): React.JSX.Element { + return ( + + {title} + {onClose ? ( + + + x + + + ) : null} + + ); +} + +// ---- Menu bar ("File", "Edit"…) with simple drop-down ---- + +export type MenuItem = { + label: string; + onPress?: () => void; + separator?: boolean; + disabled?: boolean; +}; + +export type MenuSpec = { label: string; items: MenuItem[] }; + +export function MenuBar({ menus }: { menus: MenuSpec[] }): React.JSX.Element { + const [open, setOpen] = useState(null); + // Pixel-position of the open menu label so the dropdown anchors to it. + const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + const labelRefs = useRef>({}); + + const openMenu = (label: string) => { + const ref = labelRefs.current[label]; + if (ref) { + ref.measureInWindow((x, y, _w, h) => { + setAnchor({ x, y: y + h }); + setOpen(label); + }); + } else { + setOpen(label); + } + }; + + const close = () => { + setOpen(null); + setAnchor(null); + }; + + const items = open ? menus.find((m) => m.label === open)?.items ?? [] : []; + + return ( + + + {menus.map((m) => ( + { labelRefs.current[m.label] = r; }} + onPress={() => (open === m.label ? close() : openMenu(m.label))} + style={[styles.menuLabel, open === m.label && styles.menuLabelOpen]} + > + + {m.label} + + + ))} + + + {/* Backdrop captures taps outside the menu so any tap closes it. */} + + {/* Inner Pressable swallows the tap so clicks inside the + dropdown don't bubble to the backdrop and dismiss it. */} + {}} + style={[ + styles.menuFloatingWrap, + anchor ? { top: anchor.y, left: anchor.x } : null, + ]} + > + + {items.map((it, i) => + it.separator ? ( + + ) : ( + { + close(); + it.onPress?.(); + }} + style={({ pressed }) => [ + styles.menuItem, + pressed && styles.menuItemPressed, + ]} + > + + {it.label} + + + ), + )} + + + + + + ); +} + +// ---- Status bar (bottom strip used like a Win95 app status line) ---- + +export function StatusBar({ children }: { children: ReactNode }): React.JSX.Element { + return {typeof children === 'string' + ? {children} + : children}; +} + +export function Field({ children, style }: { children: ReactNode; style?: ViewStyle }): React.JSX.Element { + return {children}; +} + +export function PixelText({ children, style, ...rest }: TextProps): React.JSX.Element { + return {children}; +} + +const styles = StyleSheet.create({ + frameOuter: { + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + frameInner: { + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.bg, borderLeftColor: theme.bg, + borderRightColor: theme.shadow, borderBottomColor: theme.shadow, + }, + inset: { + backgroundColor: theme.inset, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + field: { paddingHorizontal: 8, paddingVertical: 6 }, + btnOuter: { + backgroundColor: theme.bg, + paddingHorizontal: 14, paddingVertical: 8, + }, + btnSmall: { paddingHorizontal: 10, paddingVertical: 4 }, + btnInner: { alignItems: 'center', justifyContent: 'center' }, + btnInnerSmall: {}, + btnPrimary: {}, + btnText: { + fontFamily: PIXEL_FONT, + fontSize: 18, + color: theme.text, + includeFontPadding: false as any, + }, + btnTextPrimary: { fontWeight: '700' as const }, + titleBar: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: theme.titleBg, + paddingHorizontal: 4, paddingVertical: 2, + }, + titleText: { + flex: 1, color: theme.titleFg, + fontFamily: PIXEL_FONT, fontSize: 18, fontWeight: '700', + paddingHorizontal: 4, + }, + titleCloseOuter: { padding: 2 }, + titleCloseInner: { + width: 20, height: 18, + backgroundColor: theme.bg, + alignItems: 'center', justifyContent: 'center', + }, + titleCloseX: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text, lineHeight: 16 }, + menuBarWrap: { position: 'relative', zIndex: 10 }, + menuBar: { + flexDirection: 'row', + backgroundColor: theme.bg, + paddingHorizontal: 2, paddingVertical: 2, + borderBottomWidth: 1, borderBottomColor: theme.shadow, + }, + menuLabel: { paddingHorizontal: 8, paddingVertical: 4 }, + menuLabelOpen: { backgroundColor: theme.selBg }, + menuLabelText: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, + menuLabelTextOpen: { color: theme.selFg }, + menuBackdrop: { flex: 1 }, + menuFloatingWrap: { position: 'absolute' }, + menuDropdown: { + minWidth: 220, + paddingVertical: 2, + }, + menuItem: { paddingHorizontal: 16, paddingVertical: 4 }, + menuItemPressed: { backgroundColor: theme.selBg }, + menuItemText: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, + menuItemDisabled: { color: theme.shadow }, + menuSeparator: { + height: 2, marginVertical: 3, marginHorizontal: 4, + borderTopWidth: 1, borderTopColor: theme.shadow, + borderBottomWidth: 1, borderBottomColor: theme.highlight, + }, + statusBar: { + flexDirection: 'row', + backgroundColor: theme.bg, + paddingHorizontal: 4, paddingVertical: 4, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, + statusText: { fontFamily: PIXEL_FONT, fontSize: 14, color: theme.text }, + pixel: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, +}); diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..8e10e56 --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,90 @@ +// Windows 95 design system. All visual chrome (buttons, frames, title +// bars, menu bars) lives here so we can re-skin the whole app from one +// place. The Manta is monochrome e-ink, so the "color" palette is +// pragmatic: shades of gray that render as flat tones (or dithered) +// on-device. The bevel illusion comes from contrasting border colors +// rather than gradients. + +import { TextStyle, ViewStyle } from 'react-native'; + +export const theme = { + // Classic Win95 silver. On the Manta this comes out as a light gray; + // the bevels still read clearly. + bg: '#c0c0c0', + panel: '#c0c0c0', + inset: '#ffffff', // input / list backgrounds + highlight: '#ffffff', // top + left edge of raised controls + shadow: '#808080', // mid bevel + dark: '#000000', // outer dark edge + text: '#000000', + textMuted: '#3f3f3f', + // "Active title bar" blue from Win95. Manta will render it dark; fine. + titleBg: '#000080', + titleFg: '#ffffff', + selBg: '#000080', + selFg: '#ffffff', + desktop: '#008080', // teal — region picker uses this +}; + +// Bundle this in android/app/src/main/assets/fonts/ as VT323-Regular.ttf +// to get true pixel typography. Falls back to monospace if missing. +// curl -L "https://fonts.gstatic.com/s/vt323/v17/pxiKyp0ihIEF2isfFJU.ttf" \ +// -o android/app/src/main/assets/fonts/VT323-Regular.ttf +export const PIXEL_FONT = 'VT323'; +export const SANS_FONT = 'monospace'; + +// Outset bevel: highlight on top/left, shadow on bottom/right, plus a +// 1px black outer ring. The doubled-up border-style approach RN doesn't +// support, so we layer two Views (see Win95Frame). +export function outsetEdges(): ViewStyle { + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.highlight, + borderLeftColor: theme.highlight, + borderRightColor: theme.dark, + borderBottomColor: theme.dark, + }; +} + +export function insetEdges(): ViewStyle { + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.dark, + borderLeftColor: theme.dark, + borderRightColor: theme.highlight, + borderBottomColor: theme.highlight, + }; +} + +export function inner3D(): ViewStyle { + // Secondary inner bevel — combined with outsetEdges this yields the + // double-line raised look used by Win95 buttons. + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.bg, + borderLeftColor: theme.bg, + borderRightColor: theme.shadow, + borderBottomColor: theme.shadow, + }; +} + +export const pixelText: TextStyle = { + fontFamily: PIXEL_FONT, + fontSize: 16, + color: theme.text, +}; + +export const sansText: TextStyle = { + fontFamily: SANS_FONT, + fontSize: 12, + color: theme.text, +}; diff --git a/src/useConnStatus.ts b/src/useConnStatus.ts new file mode 100644 index 0000000..6891152 --- /dev/null +++ b/src/useConnStatus.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { lanJson } from './imageProcessor'; + +// Polls the Mac /status endpoint to expose a coarse connection state to +// the UI's status dot. Each consumer screen creates its own hook instance; +// network use is tiny (small JSON every few seconds) and identity +// adjustments / capture polling are unaffected. +export type ConnStatus = 'unknown' | 'connected' | 'disconnected'; + +const POLL_MS = 5000; +const TIMEOUT_MS = 2000; + +export function useConnStatus(baseUrl: string): ConnStatus { + const [status, setStatus] = useState('unknown'); + + useEffect(() => { + if (!baseUrl) { + setStatus('unknown'); + return; + } + let alive = true; + let timer: ReturnType | null = null; + + const tick = async () => { + try { + await lanJson('GET', `${baseUrl}/status`, undefined, TIMEOUT_MS); + if (alive) setStatus('connected'); + } catch { + if (alive) setStatus('disconnected'); + } finally { + if (alive) timer = setTimeout(tick, POLL_MS); + } + }; + tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + }; + }, [baseUrl]); + + return status; +} diff --git a/src/util/FileLogger.ts b/src/util/FileLogger.ts new file mode 100644 index 0000000..2317930 --- /dev/null +++ b/src/util/FileLogger.ts @@ -0,0 +1,71 @@ +import { ImageProcessor } from '../imageProcessor'; + +// Inkling-inspired persistent logger. Every call still hits console.log +// (so adb logcat keeps working) but also appends to a flat file the user +// can pull with `adb pull /sdcard/EmbedImage/log.txt`. Crucial when the +// RN bridge dies before logcat catches up. +const LOG_FILE = '/sdcard/EmbedImage/log.txt'; +const MAX_QUEUED = 200; + +let queued: string[] = []; +let flushing = false; + +function ts(): string { + const d = new Date(); + const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +async function flush(): Promise { + if (flushing || queued.length === 0) return; + flushing = true; + const batch = queued.join('\n'); + queued = []; + try { + if (ImageProcessor?.appendFile) { + await ImageProcessor.appendFile(LOG_FILE, batch); + } + } catch { + // best-effort; if /sdcard isn't writable we just lose this batch. + } finally { + flushing = false; + if (queued.length > 0) flush(); + } +} + +function enqueue(line: string) { + queued.push(line); + if (queued.length > MAX_QUEUED) queued = queued.slice(-MAX_QUEUED); + // Fire-and-forget; we never want logging to block the caller. + flush(); +} + +export const FileLogger = { + path: LOG_FILE, + + log(tag: string, msg: string, extra?: any): void { + const formatted = extra !== undefined + ? `${ts()} [${tag}] ${msg} ${typeof extra === 'string' ? extra : safeStringify(extra)}` + : `${ts()} [${tag}] ${msg}`; + console.log(`[embedimage:${tag}]`, msg, extra ?? ''); + enqueue(formatted); + }, + + // Variadic drop-in for "this used to be console.log(a, b, c)". + raw(...parts: any[]): void { + console.log(...parts); + const stringified = parts + .map((p) => (typeof p === 'string' ? p : safeStringify(p))) + .join(' '); + enqueue(`${ts()} ${stringified}`); + }, +}; + +function safeStringify(obj: any): string { + try { + const s = JSON.stringify(obj); + return s.length > 800 ? s.slice(0, 800) + '…' : s; + } catch { + return String(obj); + } +}