From 1848a88fcfcbaf1791bab95d9d534ca66b390980 Mon Sep 17 00:00:00 2001 From: MobKBK <15059009+mobkbk@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 17:03:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实时图传:WebSocket JPEG 帧发送 + 帧率控制 + PC 浏览器预览 - PDF 上传与处理:上传/处理分离,支持 ocrpdf 和 markdown 两种类型 - MinerU 真实接入:markdown 处理 + images ZIP 打包 - OCRmyPDF 接入:ocrpdf 生成可搜索双层 PDF - 手机端任务管理面板:轮询状态 + SAF 目录选择下载 - PC 管理面板:/dashboard 文件与任务管理 - 网络层:OkHttp 客户端、WebSocket 图传、局域网发现占位 Co-Authored-By: Claude Opus 4.6 --- .idea/.gitignore | 3 - .idea/AndroidProjectSystem.xml | 6 - .idea/compiler.xml | 6 - .idea/gradle.xml | 21 - .idea/inspectionProfiles/Project_Default.xml | 61 - .idea/migrations.xml | 10 - .idea/misc.xml | 9 - .idea/runConfigurations.xml | 17 - .idea/vcs.xml | 6 - app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 9 +- .../main/java/org/fairscan/app/FairScanApp.kt | 18 + .../java/org/fairscan/app/MainActivity.kt | 16 + .../app/network/NetworkInfoProvider.kt | 34 + .../fairscan/app/network/ServerEndpoint.kt | 24 + .../app/network/discovery/DiscoveredHost.kt | 24 + .../app/network/discovery/DiscoveryState.kt | 23 + .../network/discovery/LanServiceDiscovery.kt | 22 + .../app/network/stream/FrameCompressor.kt | 53 + .../app/network/stream/FrameDropController.kt | 53 + .../app/network/stream/OkHttpStreamClient.kt | 86 + .../app/network/stream/StreamQualityPreset.kt | 32 + .../app/network/stream/StreamState.kt | 22 + .../fairscan/app/network/tasks/TaskClient.kt | 188 +++ .../fairscan/app/network/tasks/TaskModels.kt | 46 + .../app/network/upload/PdfUploadClient.kt | 124 ++ .../app/ui/screens/camera/CameraScreen.kt | 67 + .../app/ui/screens/camera/CameraViewModel.kt | 116 +- .../app/ui/screens/export/ExportScreen.kt | 485 +++++- .../app/ui/screens/export/ExportUiState.kt | 45 + .../app/ui/screens/export/ExportViewModel.kt | 246 +++ .../ui/screens/settings/SettingsRepository.kt | 150 ++ .../app/ui/screens/settings/SettingsScreen.kt | 191 +++ .../ui/screens/settings/SettingsViewModel.kt | 85 +- app/src/main/res/drawable/icon.png | Bin 21602 -> 69366 bytes .../main/res/mipmap-anydpi/ic_launcher.xml | 4 +- .../res/mipmap-anydpi/ic_launcher_round.xml | 4 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2974 bytes app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1770 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2974 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 3540 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1975 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 1052 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1975 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 2242 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3986 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 2272 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3986 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 4788 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6323 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 3342 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 6323 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 7312 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 8563 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 4630 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 8563 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 9776 -> 0 bytes .../res/values/ic_launcher_background.xml | 2 +- app/src/main/res/values/strings.xml | 3 + .../main/res/xml/network_security_config.xml | 4 + gradle/libs.versions.toml | 2 + pc-server/README.md | 15 + pc-server/main.py | 800 ++++++++++ pc-server/requirements.txt | 4 + requirements/FIXES_SUMMARY.md | 195 +++ requirements/FairScan_reqirement.prg | Bin 0 -> 2229 bytes requirements/IMPLEMENTATION_COMPLETE.md | 293 ++++ requirements/NEXT_STEPS.md | 145 ++ requirements/implementation-plan.md | 1385 +++++++++++++++++ requirements/mineru-integration.md | 392 +++++ requirements/pc-api-spec.md | 789 ++++++++++ requirements/requirements.md | 108 ++ 72 files changed, 6281 insertions(+), 163 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/AndroidProjectSystem.xml delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/migrations.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml create mode 100644 app/src/main/java/org/fairscan/app/network/NetworkInfoProvider.kt create mode 100644 app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt create mode 100644 app/src/main/java/org/fairscan/app/network/discovery/DiscoveredHost.kt create mode 100644 app/src/main/java/org/fairscan/app/network/discovery/DiscoveryState.kt create mode 100644 app/src/main/java/org/fairscan/app/network/discovery/LanServiceDiscovery.kt create mode 100644 app/src/main/java/org/fairscan/app/network/stream/FrameCompressor.kt create mode 100644 app/src/main/java/org/fairscan/app/network/stream/FrameDropController.kt create mode 100644 app/src/main/java/org/fairscan/app/network/stream/OkHttpStreamClient.kt create mode 100644 app/src/main/java/org/fairscan/app/network/stream/StreamQualityPreset.kt create mode 100644 app/src/main/java/org/fairscan/app/network/stream/StreamState.kt create mode 100644 app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt create mode 100644 app/src/main/java/org/fairscan/app/network/tasks/TaskModels.kt create mode 100644 app/src/main/java/org/fairscan/app/network/upload/PdfUploadClient.kt create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 pc-server/README.md create mode 100644 pc-server/main.py create mode 100644 pc-server/requirements.txt create mode 100644 requirements/FIXES_SUMMARY.md create mode 100644 requirements/FairScan_reqirement.prg create mode 100644 requirements/IMPLEMENTATION_COMPLETE.md create mode 100644 requirements/NEXT_STEPS.md create mode 100644 requirements/implementation-plan.md create mode 100644 requirements/mineru-integration.md create mode 100644 requirements/pc-api-spec.md create mode 100644 requirements/requirements.md diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee..0000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index f64ba30..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 7061a0d..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml deleted file mode 100644 index f8051a6..0000000 --- a/.idea/migrations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d852bbf..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 322eb6d..aef7a05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,6 +136,7 @@ dependencies { implementation(libs.reorderable) implementation(libs.aboutlibraries.compose.m3) implementation(libs.kotlinx.serialization.json) +implementation(libs.okhttp) testImplementation(libs.junit) testImplementation(libs.assertj) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6ed47b3..a26f9c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,10 @@ - - - + + + + viewModelFactory( crossinline create: (AppContainer) -> VM diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index b59c024..087e293 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -126,6 +126,7 @@ class MainActivity : ComponentActivity() { val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle() val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle() val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() + val taskPanelState by exportViewModel.taskPanelState.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() CollectCameraEvents(cameraViewModel, viewModel) CollectExportEvents(context, exportViewModel) @@ -211,7 +212,14 @@ class MainActivity : ComponentActivity() { share = { exportViewModel.onShareClicked() }, save = { exportViewModel.onSaveClicked() }, open = { item -> openUri(item.uri, item.format.mimeType, logger) }, + uploadToPc = { exportViewModel.uploadPdfToServer() }, + uploadAndProcess = { processType -> exportViewModel.uploadAndProcess(processType) }, + downloadResult = { task, destDirUri, context -> + exportViewModel.downloadResult(task, destDirUri, context) + }, + resetDownloadState = { exportViewModel.resetDownloadState() }, ), + taskPanelState = taskPanelState, onCloseScan = { exportViewModel.resetFilename() viewModel.startNewDocument() @@ -295,6 +303,14 @@ class MainActivity : ComponentActivity() { onResetExportDirClick = { settingsViewModel.setExportDirUri(null) }, onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) }, onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) }, + onServerHostChanged = { host -> settingsViewModel.setServerHost(host) }, + onServerPortChanged = { port -> settingsViewModel.setServerPort(port) }, + onStreamQualityChanged = { quality -> settingsViewModel.setStreamQuality(quality) }, + onPostProcessModeChanged = { mode -> settingsViewModel.setPostProcessMode(mode) }, + onAutoDownloadChanged = { enabled -> settingsViewModel.setAutoDownloadProcessedResult(enabled) }, + onStreamFrameRateChanged = { rate -> settingsViewModel.setStreamFrameRate(rate) }, + onScanNetworkHostsClick = { /* TODO: Implement network discovery */ }, + onTestConnectionClick = { /* TODO: Implement connection test */ }, onBack = nav.back, ) } diff --git a/app/src/main/java/org/fairscan/app/network/NetworkInfoProvider.kt b/app/src/main/java/org/fairscan/app/network/NetworkInfoProvider.kt new file mode 100644 index 0000000..44188fd --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/NetworkInfoProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network + +import java.net.Inet4Address +import java.net.NetworkInterface + +class NetworkInfoProvider { + + fun getLocalIpAddress(): String? { + return try { + NetworkInterface.getNetworkInterfaces().asSequence() + .flatMap { it.inetAddresses.asSequence() } + .filterNot { it.isLoopbackAddress } + .filterIsInstance() + .firstOrNull() + ?.hostAddress + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt b/app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt new file mode 100644 index 0000000..838cac2 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network + +data class ServerEndpoint( + val host: String, + val port: Int, + val protocol: String = "http", +) { + val url: String get() = "$protocol://$host:$port" + val wsUrl: String get() = "ws://$host:$port" +} diff --git a/app/src/main/java/org/fairscan/app/network/discovery/DiscoveredHost.kt b/app/src/main/java/org/fairscan/app/network/discovery/DiscoveredHost.kt new file mode 100644 index 0000000..c5b0485 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/discovery/DiscoveredHost.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.discovery + +data class DiscoveredHost( + val serviceName: String, + val displayName: String, + val host: String, + val port: Int, + val features: List = emptyList(), + val version: String? = null, +) diff --git a/app/src/main/java/org/fairscan/app/network/discovery/DiscoveryState.kt b/app/src/main/java/org/fairscan/app/network/discovery/DiscoveryState.kt new file mode 100644 index 0000000..2955ca1 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/discovery/DiscoveryState.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.discovery + +sealed class DiscoveryState { + data object Idle : DiscoveryState() + data object Discovering : DiscoveryState() + data class Success(val hosts: List) : DiscoveryState() + data object Empty : DiscoveryState() + data class Error(val message: String) : DiscoveryState() +} diff --git a/app/src/main/java/org/fairscan/app/network/discovery/LanServiceDiscovery.kt b/app/src/main/java/org/fairscan/app/network/discovery/LanServiceDiscovery.kt new file mode 100644 index 0000000..bccaeca --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/discovery/LanServiceDiscovery.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.discovery + +import kotlinx.coroutines.flow.Flow + +interface LanServiceDiscovery { + suspend fun startDiscovery(serviceType: String): Flow + suspend fun stopDiscovery() +} diff --git a/app/src/main/java/org/fairscan/app/network/stream/FrameCompressor.kt b/app/src/main/java/org/fairscan/app/network/stream/FrameCompressor.kt new file mode 100644 index 0000000..e25a069 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/stream/FrameCompressor.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.stream + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.ByteArrayOutputStream + +class FrameCompressor { + + /** + * Compress a bitmap to JPEG bytes with resize and quality settings. + * Returns null if compression fails. + */ + fun compress( + source: Bitmap, + maxDimension: Int, + jpegQuality: Int, + ): ByteArray? { + return try { + val resized = resizeIfNeeded(source, maxDimension) + val output = ByteArrayOutputStream() + resized.compress(Bitmap.CompressFormat.JPEG, jpegQuality, output) + output.toByteArray() + } catch (e: Exception) { + null + } + } + + private fun resizeIfNeeded(bitmap: Bitmap, maxDimension: Int): Bitmap { + val width = bitmap.width + val height = bitmap.height + val max = maxOf(width, height) + if (max <= maxDimension) return bitmap + + val ratio = maxDimension.toFloat() / max + val newWidth = (width * ratio).toInt() + val newHeight = (height * ratio).toInt() + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + } +} diff --git a/app/src/main/java/org/fairscan/app/network/stream/FrameDropController.kt b/app/src/main/java/org/fairscan/app/network/stream/FrameDropController.kt new file mode 100644 index 0000000..3ce6966 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/stream/FrameDropController.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.stream + +/** + * Controls frame dropping based on minimum interval between sends. + * If a frame arrives before the minimum interval has elapsed, it is dropped. + */ +class FrameDropController { + @Volatile + private var lastSendTimeMs: Long = 0L + + private val isSending = java.util.concurrent.atomic.AtomicBoolean(false) + + /** + * Returns true if this frame should be dropped. + * @param minIntervalMs Minimum interval between frames in ms. + * If <= 0, no time-based dropping (only isSending guard). + */ + fun shouldDrop(minIntervalMs: Long): Boolean { + if (isSending.get()) return true + // Unlimited mode: no time-based dropping + if (minIntervalMs <= 0) return false + val now = System.currentTimeMillis() + if (now - lastSendTimeMs < minIntervalMs) return true + return false + } + + fun onFrameSent() { + lastSendTimeMs = System.currentTimeMillis() + } + + fun markSending(value: Boolean) { + isSending.set(value) + } + + fun reset() { + lastSendTimeMs = 0L + isSending.set(false) + } +} diff --git a/app/src/main/java/org/fairscan/app/network/stream/OkHttpStreamClient.kt b/app/src/main/java/org/fairscan/app/network/stream/OkHttpStreamClient.kt new file mode 100644 index 0000000..aca91b2 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/stream/OkHttpStreamClient.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.stream + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.OkHttpClient +import okio.ByteString.Companion.toByteString +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.fairscan.app.network.ServerEndpoint +import java.util.concurrent.TimeUnit + +class OkHttpStreamClient( + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) // No read timeout for streaming + .writeTimeout(0, TimeUnit.SECONDS) // No write timeout for streaming + .build(), +) : StreamClient { + + private val _state = MutableStateFlow(StreamState.Disconnected) + override val state: StateFlow = _state.asStateFlow() + + private var webSocket: WebSocket? = null + + override suspend fun connect(endpoint: ServerEndpoint) { + if (_state.value is StreamState.Connected || _state.value is StreamState.Connecting) return + + _state.value = StreamState.Connecting + val request = Request.Builder() + .url("ws://${endpoint.host}:${endpoint.port}/stream") + .build() + + webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _state.value = StreamState.Connected + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + _state.value = StreamState.Error(t.message ?: "Connection failed") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + _state.value = StreamState.Disconnected + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, "Client closing") + } + }) + } + + override fun sendFrame(frameData: ByteArray): Boolean { + val ws = webSocket ?: return false + return ws.send(frameData.toByteString()) + } + + override suspend fun disconnect() { + webSocket?.close(1000, "Client disconnect") + webSocket = null + _state.value = StreamState.Disconnected + } +} + +interface StreamClient { + val state: StateFlow + suspend fun connect(endpoint: ServerEndpoint) + fun sendFrame(frameData: ByteArray): Boolean + suspend fun disconnect() +} diff --git a/app/src/main/java/org/fairscan/app/network/stream/StreamQualityPreset.kt b/app/src/main/java/org/fairscan/app/network/stream/StreamQualityPreset.kt new file mode 100644 index 0000000..218ec6c --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/stream/StreamQualityPreset.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.stream + +import org.fairscan.app.ui.screens.settings.StreamQuality + +data class StreamQualityPreset( + val label: String, + val maxResolution: Int, + val jpegQuality: Int, + val targetFps: Int, +) { + val minIntervalMs: Long get() = (1000L / targetFps).coerceAtLeast(50L) +} + +fun StreamQuality.toPreset(): StreamQualityPreset = when (this) { + StreamQuality.LOW -> StreamQualityPreset("Low", 640, 45, 10) + StreamQuality.BALANCED -> StreamQualityPreset("Balanced", 960, 60, 8) + StreamQuality.HIGH -> StreamQualityPreset("High", 1280, 75, 6) +} diff --git a/app/src/main/java/org/fairscan/app/network/stream/StreamState.kt b/app/src/main/java/org/fairscan/app/network/stream/StreamState.kt new file mode 100644 index 0000000..73a2a2d --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/stream/StreamState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.stream + +sealed class StreamState { + data object Disconnected : StreamState() + data object Connecting : StreamState() + data object Connected : StreamState() + data class Error(val message: String) : StreamState() +} diff --git a/app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt b/app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt new file mode 100644 index 0000000..de31e96 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.tasks + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.fairscan.app.network.ServerEndpoint +import org.fairscan.app.network.upload.PdfUploadClient +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Client for task management operations on the FairScan PC server. + */ +class TaskClient( + private val okHttpClient: OkHttpClient, +) { + private val downloadClient = okHttpClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .build() + + /** + * Create a processing task for an uploaded PDF. + * + * @param endpoint Server endpoint. + * @param fileName Name of the uploaded PDF file. + * @param mode Processing mode (e.g., "OCRPdf" or "Markdown"). + * @return ProcessTaskResult with the assigned task ID. + */ + fun processPdf( + endpoint: ServerEndpoint, + fileId: String, + processType: String = "ocrpdf", + ): ProcessTaskResult { + val url = "${endpoint.url}/tasks/process" + val json = """{"fileId":"$fileId","processType":"$processType"}""" + val requestBody = json.toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(url) + .post(requestBody) + .build() + + val response = okHttpClient.newCall(request).execute() + val body = response.body?.string() ?: throw IOException("Empty response") + + if (!response.isSuccessful) { + throw IOException("Failed to create task (${response.code}): $body") + } + + val taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: "" + val status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown" + return ProcessTaskResult(taskId, status, "") + } + + /** + * Get the current status of a processing task. + */ + fun getTaskStatus(endpoint: ServerEndpoint, taskId: String): TaskStatus { + val url = "${endpoint.url}/tasks/$taskId" + val request = Request.Builder().url(url).get().build() + val response = okHttpClient.newCall(request).execute() + val body = response.body?.string() ?: throw IOException("Empty response") + + if (!response.isSuccessful) { + throw IOException("Failed to get task status (${response.code}): $body") + } + + return TaskStatus( + taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: taskId, + status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown", + progress = extractJsonInt(body, "progress") ?: 0, + fileName = PdfUploadClient.extractJsonString(body, "fileName") ?: "", + createdAt = PdfUploadClient.extractJsonString(body, "createdAt") ?: "", + message = PdfUploadClient.extractJsonString(body, "message") ?: "", + ) + } + + /** + * List artifacts (result files) for a completed task. + */ + fun listArtifacts(endpoint: ServerEndpoint, taskId: String): List { + val url = "${endpoint.url}/tasks/$taskId/artifacts" + val request = Request.Builder().url(url).get().build() + val response = okHttpClient.newCall(request).execute() + val body = response.body?.string() ?: throw IOException("Empty response") + + if (!response.isSuccessful) { + throw IOException("Failed to list artifacts (${response.code}): $body") + } + + return parseArtifactList(body) + } + + /** + * Download an artifact to a destination file. + * + * @return The destination file (same as [destFile]). + */ + fun downloadArtifact( + endpoint: ServerEndpoint, + artifactId: String, + destFile: File, + onProgress: ((Float) -> Unit)? = null, + ): File { + val url = "${endpoint.url}/artifacts/$artifactId/download" + val request = Request.Builder().url(url).get().build() + + val response = downloadClient.newCall(request).execute() + if (!response.isSuccessful) { + throw IOException("Failed to download artifact (${response.code})") + } + + val body = response.body ?: throw IOException("Empty response body") + + destFile.parentFile?.mkdirs() + val total = body.contentLength() + var bytesRead = 0L + + body.byteStream().use { input -> + FileOutputStream(destFile).use { output -> + val buffer = ByteArray(8192) + var read: Int + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + bytesRead += read + if (onProgress != null && total > 0) { + onProgress(bytesRead.toFloat() / total) + } + } + } + } + + return destFile + } + + private fun parseArtifactList(json: String): List { + val artifacts = mutableListOf() + var pos = json.indexOf('[') + if (pos < 0) return artifacts + + pos = json.indexOf('{', pos) + while (pos >= 0) { + val end = json.indexOf('}', pos) + if (end < 0) break + val obj = json.substring(pos, end + 1) + val id = PdfUploadClient.extractJsonString(obj, "id") + ?: PdfUploadClient.extractJsonString(obj, "artifactId") + val fileName = PdfUploadClient.extractJsonString(obj, "fileName") ?: "" + val fileSize = extractJsonLong(obj, "fileSize") ?: 0L + val fileType = PdfUploadClient.extractJsonString(obj, "fileType") ?: "" + if (id != null) { + artifacts.add(ArtifactInfo(id, fileName, fileSize, fileType)) + } + pos = json.indexOf('{', end + 1) + } + return artifacts + } + + companion object { + fun extractJsonInt(json: String, key: String): Int? { + val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex() + return pattern.find(json)?.groupValues?.getOrNull(1)?.toIntOrNull() + } + + fun extractJsonLong(json: String, key: String): Long? { + val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex() + return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull() + } + } +} diff --git a/app/src/main/java/org/fairscan/app/network/tasks/TaskModels.kt b/app/src/main/java/org/fairscan/app/network/tasks/TaskModels.kt new file mode 100644 index 0000000..4977f84 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/tasks/TaskModels.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.tasks + +/** + * Status of a processing task on the PC server. + */ +data class TaskStatus( + val taskId: String, + val status: String, // queued, processing, completed, failed + val progress: Int = 0, // 0-100 + val fileName: String = "", + val createdAt: String = "", + val message: String = "", +) + +/** + * Information about a processed artifact (result file) on the PC server. + */ +data class ArtifactInfo( + val artifactId: String, + val fileName: String, + val fileSize: Long = 0, + val fileType: String = "", // "pdf", "markdown", "md", etc. +) + +/** + * Result of creating a processing task. + */ +data class ProcessTaskResult( + val taskId: String, + val status: String, + val message: String = "", +) diff --git a/app/src/main/java/org/fairscan/app/network/upload/PdfUploadClient.kt b/app/src/main/java/org/fairscan/app/network/upload/PdfUploadClient.kt new file mode 100644 index 0000000..06ba895 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/network/upload/PdfUploadClient.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.network.upload + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.fairscan.app.network.ServerEndpoint +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Result of a PDF upload operation. + */ +data class UploadResult( + val fileId: String, + val fileName: String = "", + val sizeBytes: Long = 0, +) + +/** + * Client for uploading PDF files to the FairScan PC server. + */ +class PdfUploadClient( + private val okHttpClient: OkHttpClient, +) { + private val uploadTimeoutClient = okHttpClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) // Large files need time to upload + .build() + + /** + * Upload a PDF file to the PC server. + * + * @param endpoint The server endpoint to upload to. + * @param file The PDF file to upload. + * @param onProgress Callback with progress 0.0..1.0 (approximate, based on bytes written). + * @return UploadResult with the task ID assigned by the server. + * @throws IOException on network or server error. + */ + fun uploadPdf( + endpoint: ServerEndpoint, + file: File, + onProgress: ((Float) -> Unit)? = null, + ): UploadResult { + val url = "${endpoint.url}/upload/pdf" + + val fileBody = object : RequestBody() { + override fun contentType() = "application/pdf".toMediaType() + override fun contentLength() = file.length() + + override fun writeTo(sink: okio.BufferedSink) { + val buffer = ByteArray(8192) + val total = file.length() + var written = 0L + file.inputStream().use { input -> + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + written += bytesRead + if (onProgress != null && total > 0) { + onProgress(written.toFloat() / total) + } + } + } + } + } + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, fileBody) + .build() + + val request = Request.Builder() + .url(url) + .post(requestBody) + .build() + + val response = uploadTimeoutClient.newCall(request).execute() + val responseBody = response.body?.string() ?: throw IOException("Empty response from server") + + if (!response.isSuccessful) { + throw IOException("Upload failed (${response.code}): $responseBody") + } + + // Parse JSON response — simple manual parse to avoid adding a JSON library + return parseUploadResponse(responseBody) + } + + private fun parseUploadResponse(json: String): UploadResult { + val fileId = extractJsonString(json, "fileId") ?: "" + val fileName = extractJsonString(json, "fileName") ?: "" + val sizeBytes = extractJsonLong(json, "sizeBytes") ?: 0L + return UploadResult(fileId, fileName, sizeBytes) + } + + companion object { + fun extractJsonString(json: String, key: String): String? { + val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"".toRegex() + return pattern.find(json)?.groupValues?.getOrNull(1) + } + + fun extractJsonLong(json: String, key: String): Long? { + val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex() + return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull() + } + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 873bfd5..3f4b4cf 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Highlight import androidx.compose.material3.Button @@ -104,6 +105,7 @@ import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.Rotation.R0 +import org.fairscan.app.network.stream.StreamState import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Screen import org.fairscan.app.ui.components.CameraPermissionState @@ -140,6 +142,10 @@ fun CameraScreen( val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle() var torchReapplied by remember { mutableStateOf(false) } + // Streaming state + val streamState by cameraViewModel.streamState.collectAsStateWithLifecycle() + val streamTargetHost by cameraViewModel.streamTargetHost.collectAsStateWithLifecycle() + val captureController = remember { CameraCaptureController() } DisposableEffect(Unit) { onDispose { @@ -245,6 +251,9 @@ fun CameraScreen( isCameraPermissionGranted = cameraPermission.isGranted, onRequestCameraPermission = { cameraPermission.request() }, onImportClicked = onImportClicked, + streamState = streamState, + streamTargetHost = streamTargetHost, + onToggleStream = { cameraViewModel.toggleStreaming() }, ) } @@ -263,6 +272,9 @@ private fun CameraScreenScaffold( isCameraPermissionGranted: Boolean, onRequestCameraPermission: () -> Unit, onImportClicked: () -> Unit, + streamState: StreamState, + streamTargetHost: String?, + onToggleStream: () -> Unit, ) { var focusPoint by remember { mutableStateOf(null) } LaunchedEffect(focusPoint) { @@ -322,6 +334,15 @@ private fun CameraScreenScaffold( val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap() CapturedImage(page.asImageBitmap(), thumbnailCoords) } + // Stream toggle button - top left of the screen + StreamToggleButton( + streamState = streamState, + streamTargetHost = streamTargetHost, + onToggle = onToggleStream, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 48.dp, start = 8.dp), + ) } } @@ -405,6 +426,49 @@ private fun CameraPreviewBox( } } +@Composable +private fun StreamToggleButton( + streamState: StreamState, + streamTargetHost: String?, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val (iconTint, statusText) = when (streamState) { + StreamState.Disconnected -> Color.Gray to "图传未连接" + StreamState.Connecting -> Color(0xFFFFA000) to "图传连接中..." + StreamState.Connected -> Color(0xFF4CAF50) to "图传已连接" + is StreamState.Error -> Color(0xFFE53935) to "图传错误" + } + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + ) { + IconButton(onClick = onToggle) { + Icon( + imageVector = Icons.Default.Cast, + contentDescription = statusText, + tint = iconTint, + ) + } + if (streamState is StreamState.Connected && streamTargetHost != null) { + Text( + text = streamTargetHost, + color = Color(0xFF4CAF50), + fontSize = 10.sp, + modifier = Modifier.padding(end = 12.dp), + ) + } + if (streamState is StreamState.Error) { + Text( + text = streamState.message, + color = Color(0xFFE53935), + fontSize = 10.sp, + modifier = Modifier.padding(end = 12.dp), + ) + } + } +} + @Composable private fun CapturedImage(image: ImageBitmap, thumbnailCoords: MutableState) { Surface( @@ -718,6 +782,9 @@ private fun ScreenPreview( isCameraPermissionGranted = isCameraPermissionGranted, onRequestCameraPermission = {}, onImportClicked = {}, + streamState = StreamState.Disconnected, + streamTargetHost = null, + onToggleStream = {}, ) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index 45e9424..6028cab 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -33,7 +33,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.AppContainer import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.network.ServerEndpoint +import org.fairscan.app.network.stream.FrameCompressor +import org.fairscan.app.network.stream.FrameDropController +import org.fairscan.app.network.stream.StreamState +import org.fairscan.app.network.stream.toPreset import org.fairscan.app.platform.extractDocumentFromBitmap +import org.fairscan.app.ui.screens.settings.StreamFrameRate +import org.fairscan.app.ui.screens.settings.StreamQuality +import org.fairscan.app.ui.screens.settings.intervalMs import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.OpticalMeasures @@ -51,6 +59,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { private val imageLoader = appContainer.imageLoader private val logger = appContainer.logger + // Streaming components + private val streamClient = appContainer.streamClient + private val frameCompressor = FrameCompressor() + private val frameDropController = FrameDropController() + private val _events = MutableSharedFlow() val events = _events.asSharedFlow() @@ -68,10 +81,49 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { private val _isTorchEnabled = MutableStateFlow(false) val isTorchEnabled: StateFlow = _isTorchEnabled + // Streaming state + private val _streamState = MutableStateFlow(StreamState.Disconnected) + val streamState: StateFlow = _streamState.asStateFlow() + + private val _streamTargetHost = MutableStateFlow(null) + val streamTargetHost: StateFlow = _streamTargetHost.asStateFlow() + + private var cachedStreamQuality = StreamQuality.BALANCED + private var cachedStreamFrameRate = StreamFrameRate.FPS_10 + init { viewModelScope.launch { imageSegmentationService.initialize() } + // Observe stream client state + viewModelScope.launch { + streamClient.state.collect { state -> + _streamState.value = state + } + } + // Observe stream quality setting + viewModelScope.launch { + settingsRepository.streamQuality.collect { quality -> + cachedStreamQuality = quality + } + } + // Observe stream frame rate setting + viewModelScope.launch { + settingsRepository.streamFrameRate.collect { rate -> + cachedStreamFrameRate = rate + } + } + // Observe server host/port for display + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + settingsRepository.serverHost, + settingsRepository.serverPort, + ) { host, port -> + if (host.isNullOrBlank()) null else "$host:$port" + }.collect { host -> + _streamTargetHost.value = host + } + } } fun resetLiveAnalysis() { @@ -103,8 +155,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { viewModelScope.launch { val rotationDegrees = imageProxy.imageInfo.rotationDegrees + val bitmap = imageProxy.toBitmap() + + // Streaming: send frame if connected (fire-and-forget on IO) + val currentHost = _streamTargetHost.value + if (_streamState.value is StreamState.Connected && currentHost != null) { + launch(Dispatchers.IO) { + sendStreamFrame(bitmap) + } + } + val result = withContext(Dispatchers.IO) { - imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap()) + imageSegmentationService.runSegmentationAndReturn(bitmap) } result?.let { @@ -230,6 +292,58 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { importJob = null _importState.value = ImportState.Idle } + + // ── Streaming ── + + fun toggleStreaming() { + viewModelScope.launch { + when (_streamState.value) { + is StreamState.Disconnected, is StreamState.Error -> startStreaming() + is StreamState.Connected -> stopStreaming() + else -> { /* Connecting — ignore */ } + } + } + } + + private suspend fun startStreaming() { + val host = settingsRepository.serverHost.first() + val port = settingsRepository.serverPort.first() + if (host.isNullOrBlank()) { + _streamState.value = StreamState.Error("未配置主机地址") + return + } + frameDropController.reset() + streamClient.connect(ServerEndpoint(host, port)) + } + + private suspend fun stopStreaming() { + streamClient.disconnect() + frameDropController.reset() + } + + private suspend fun sendStreamFrame(bitmap: Bitmap) { + if (_streamState.value !is StreamState.Connected) return + + val preset = cachedStreamQuality.toPreset() + // Use explicit frame rate if set, otherwise fall back to quality preset's default + val intervalMs = cachedStreamFrameRate.intervalMs ?: preset.minIntervalMs + if (frameDropController.shouldDrop(intervalMs)) return + + frameDropController.markSending(true) + try { + val compressed = withContext(Dispatchers.IO) { + frameCompressor.compress(bitmap, preset.maxResolution, preset.jpegQuality) + } + if (compressed != null) { + frameDropController.onFrameSent() + streamClient.sendFrame(compressed) + } + } catch (_: Exception) { + // Frame send failed — drop silently, don't affect capture + } finally { + frameDropController.markSending(false) + } + } } sealed class CaptureState { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index dd5274e..0bb3e4d 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -17,8 +17,11 @@ package org.fairscan.app.ui.screens.export import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.net.Uri import android.text.format.Formatter import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -32,6 +35,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -45,14 +49,18 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -108,6 +116,7 @@ fun ExportScreenWrapper( uiState: ExportUiState, currentDocument: DocumentUiModel, pdfActions: ExportActions, + taskPanelState: TaskPanelState, onCloseScan: () -> Unit, ) { BackHandler { navigation.back() } @@ -122,24 +131,37 @@ fun ExportScreenWrapper( pdfActions.setFilename(newName) } + val isBusy = uiState.isSaving + || uiState.uploadState is UploadState.Uploading + || taskPanelState.downloadState is DownloadState.Downloading + ExportScreen( onFilenameChange = onFilenameChange, uiState = uiState, currentDocument = currentDocument, navigation = navigation, + taskPanelState = taskPanelState, onShare = { - if (!uiState.isSaving) { - pdfActions.share() - } + if (!isBusy) pdfActions.share() }, onSave = { - if (!uiState.isSaving) { - pdfActions.save() - } + if (!isBusy) pdfActions.save() }, onOpen = pdfActions.open, + onUploadToPc = { + if (!isBusy && uiState.uploadState !is UploadState.Uploading) { + pdfActions.uploadToPc() + } + }, + onUploadAndProcess = { processType -> + if (!isBusy) { + pdfActions.uploadAndProcess(processType) + } + }, + onDownloadResult = pdfActions.downloadResult, + onResetDownloadState = pdfActions.resetDownloadState, onCloseScan = { - if (!uiState.isSaving) { + if (!isBusy) { if (uiState.hasSavedOrShared || uiState.isResumedScan) onCloseScan() else @@ -160,9 +182,14 @@ fun ExportScreen( uiState: ExportUiState, currentDocument: DocumentUiModel, navigation: Navigation, + taskPanelState: TaskPanelState = TaskPanelState(), onShare: () -> Unit, onSave: () -> Unit, onOpen: (SavedItem) -> Unit, + onUploadToPc: () -> Unit, + onUploadAndProcess: (processType: String) -> Unit, + onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> }, + onResetDownloadState: () -> Unit = {}, onCloseScan: () -> Unit, ) { Scaffold( @@ -187,7 +214,7 @@ fun ExportScreen( ) { PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick) Spacer(Modifier.weight(1f)) // push buttons down - MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan) + MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState) } } else { Row( @@ -202,7 +229,7 @@ fun ExportScreen( PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick) } Column(modifier = Modifier.weight(1f)) { - MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan) + MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState) } } @@ -363,7 +390,12 @@ private fun MainActions( uiState: ExportUiState, onShare: () -> Unit, onSave: () -> Unit, + onUploadToPc: () -> Unit, + onUploadAndProcess: (processType: String) -> Unit, onCloseScan: () -> Unit, + taskPanelState: TaskPanelState = TaskPanelState(), + onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> }, + onResetDownloadState: () -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp) @@ -394,6 +426,13 @@ private fun MainActions( ) } } + + // Upload to PC server + UploadToPcSection(uiState, onUploadToPc, onUploadAndProcess) + + // Task management panel + TaskPanelSection(taskPanelState, onDownloadResult, onResetDownloadState) + ExportButton( icon = Icons.Default.Done, text = stringResource(R.string.scan_button), @@ -404,6 +443,432 @@ private fun MainActions( } } +@Composable +private fun UploadToPcSection( + uiState: ExportUiState, + onUploadToPc: () -> Unit, + onUploadAndProcess: (processType: String) -> Unit, +) { + when (val uploadState = uiState.uploadState) { + is UploadState.Idle -> { + if (uiState.result is ExportResult.Pdf) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ExportButton( + icon = Icons.Default.CloudUpload, + text = "仅传输到电脑", + onClick = onUploadToPc, + modifier = Modifier.fillMaxWidth(), + isPrimary = false, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + ExportButton( + icon = Icons.Default.CloudUpload, + text = "上传并处理 (OCR PDF)", + onClick = { onUploadAndProcess("ocrpdf") }, + modifier = Modifier.weight(1f), + isPrimary = false, + ) + ExportButton( + icon = Icons.Default.CloudUpload, + text = "上传并处理 (Markdown)", + onClick = { onUploadAndProcess("markdown") }, + modifier = Modifier.weight(1f), + isPrimary = false, + ) + } + } + } + } + is UploadState.Uploading -> { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "上传到电脑中...", + style = MaterialTheme.typography.bodyMedium + ) + val progressPercent = (uploadState.progress * 100).toInt() + LinearProgressIndicator( + progress = { uploadState.progress }, + modifier = Modifier.fillMaxWidth() + ) + Text( + "$progressPercent%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + } + is UploadState.Uploaded -> { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 0.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Column { + Text("已上传到电脑", style = MaterialTheme.typography.bodyMedium) + val statusText = if (uploadState.taskId != null) { + "处理任务已创建 (${uploadState.taskId.take(8)}...)" + } else { + "仅传输,未处理" + } + Text( + statusText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } + } + } + is UploadState.Error -> { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + tonalElevation = 0.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.width(8.dp)) + Text( + "上传失败", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + Text( + uploadState.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + OutlinedButton( + onClick = onUploadToPc, + modifier = Modifier.align(Alignment.End) + ) { + Text("重试") + } + } + } + } + } +} + +@Composable +private fun TaskPanelSection( + taskPanelState: TaskPanelState, + onDownloadResult: (RemoteTask, Uri, Context) -> Unit, + onResetDownloadState: () -> Unit, +) { + val context = LocalContext.current + val tasks = taskPanelState.tasks + + // Track selected destination directories per task + val selectedDirs = remember { mutableStateOf(mapOf()) } + // Track which task's directory picker is active + val pickingForTask = remember { mutableStateOf(null) } + + val dirPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + val taskId = pickingForTask.value + if (taskId != null && uri != null) { + // Take persistent permission + context.contentResolver.takePersistableUriPermission( + uri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or + android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + selectedDirs.value = selectedDirs.value + (taskId to uri) + } + pickingForTask.value = null + } + + if (tasks.isEmpty()) return + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + "任务管理", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + tasks.forEach { task -> + TaskRow( + task = task, + isDownloading = taskPanelState.downloadState is DownloadState.Downloading + && (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId, + downloadProgress = if (taskPanelState.downloadState is DownloadState.Downloading + && (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId + ) (taskPanelState.downloadState as DownloadState.Downloading).progress else 0f, + isDownloaded = taskPanelState.downloadState is DownloadState.Downloaded + && (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId, + downloadedUri = if (taskPanelState.downloadState is DownloadState.Downloaded + && (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId + ) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else null, + downloadError = if (taskPanelState.downloadState is DownloadState.Error + && (taskPanelState.downloadState as DownloadState.Error).taskId == task.taskId) + (taskPanelState.downloadState as DownloadState.Error).message else null, + selectedDirUri = selectedDirs.value[task.taskId], + onSelectDir = { + pickingForTask.value = task.taskId + dirPickerLauncher.launch(null) + }, + onDownload = { destUri -> + onDownloadResult(task, destUri, context) + }, + onOpenDownloaded = { + // Open the downloaded file + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply { + val uri = if (taskPanelState.downloadState is DownloadState.Downloaded + && (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId + ) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else return@apply + setDataAndType(uri, when (task.processType) { + "markdown" -> "application/zip" + else -> "application/pdf" + }) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + context.startActivity(intent) + } catch (_: Exception) { + // No app to handle this file type + } + }, + onDismissError = onResetDownloadState, + ) + } + } + } +} + +@Composable +private fun TaskRow( + task: RemoteTask, + isDownloading: Boolean, + downloadProgress: Float, + isDownloaded: Boolean, + downloadedUri: android.net.Uri?, + downloadError: String?, + selectedDirUri: android.net.Uri?, + onSelectDir: () -> Unit, + onDownload: (Uri) -> Unit, + onOpenDownloaded: () -> Unit, + onDismissError: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Row 1: file name + type badge + status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = task.fileName.ifEmpty { task.taskId.take(8) + "..." }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + + // Process type badge + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + ) { + Text( + text = task.processType, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + + // Status badge + val (statusText, statusColor) = when (task.status) { + "queued" -> "排队中" to Color(0xFFFFA726) + "processing" -> "处理中" to Color(0xFF42A5F5) + "completed" -> "已完成" to Color(0xFF66BB6A) + "failed" -> "失败" to Color(0xFFEF5350) + else -> task.status to Color.Gray + } + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall, + color = statusColor, + ) + } + + // Row 2: Progress bar (for processing tasks) + if (task.status == "processing") { + LinearProgressIndicator( + progress = { task.progress / 100f }, + modifier = Modifier.fillMaxWidth() + ) + } + + // Row 3: Error message (for failed tasks) + if (task.status == "failed" && task.message.isNotEmpty()) { + Text( + text = task.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + maxLines = 2, + ) + } + + // Row 4: Download actions for completed tasks + if (task.status == "completed" && !isDownloaded) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (selectedDirUri != null) { + Button( + onClick = { onDownload(selectedDirUri) }, + enabled = !isDownloading, + contentPadding = PaddingValues( + start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp + ), + modifier = Modifier.heightIn(min = 32.dp) + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(4.dp)) + Text( + "${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall + ) + } else { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("下载", style = MaterialTheme.typography.labelSmall) + } + } + } + + OutlinedButton( + onClick = onSelectDir, + contentPadding = PaddingValues( + start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp + ), + modifier = Modifier.heightIn(min = 32.dp) + ) { + Icon( + Icons.Default.Download, // reuse download icon for simplicity + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + if (selectedDirUri != null) "已选目录" else "选择目录", + style = MaterialTheme.typography.labelSmall + ) + } + } + } + + // Row 5: Downloaded state with open button + if (isDownloaded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp) + ) + Text( + "已下载", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedButton( + onClick = onOpenDownloaded, + contentPadding = PaddingValues( + start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp + ), + modifier = Modifier.heightIn(min = 32.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("打开", style = MaterialTheme.typography.labelSmall) + } + } + } + + // Row 6: Download error + if (downloadError != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(12.dp) + ) + Text( + downloadError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + @Composable private fun ActionSurface( modifier: Modifier = Modifier, @@ -716,6 +1181,8 @@ fun ExportPreviewToCustomize(uiState: ExportUiState) { onShare = {}, onSave = {}, onOpen = {}, + onUploadToPc = {}, + onUploadAndProcess = {}, onCloseScan = {}, ) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index c60d1fc..b7e64da 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -27,10 +27,55 @@ data class ExportUiState( val hasShared: Boolean = false, val error: ExportError? = null, val isResumedScan: Boolean = false, + // Upload to PC + val uploadState: UploadState = UploadState.Idle, + // Task management panel + val taskPanelState: TaskPanelState = TaskPanelState(), ) { val hasSavedOrShared get() = savedBundle != null || hasShared } +/** State of the PDF upload to PC server. */ +sealed class UploadState { + /** No upload in progress. */ + data object Idle : UploadState() + + /** Upload is in progress with [progress] 0.0..1.0. */ + data class Uploading(val progress: Float) : UploadState() + + /** Upload completed successfully. taskId is set when processing was also requested. */ + data class Uploaded(val fileId: String, val taskId: String? = null) : UploadState() + + /** Upload failed with an error message. */ + data class Error(val message: String) : UploadState() +} + +/** A remote processing task displayed in the task management panel. */ +data class RemoteTask( + val fileId: String, + val taskId: String, + val processType: String, // "ocrpdf" | "markdown" + val status: String, // "queued" | "processing" | "completed" | "failed" + val progress: Int, // 0..100 + val fileName: String = "", + val message: String = "", +) + +/** State for the task management panel. */ +data class TaskPanelState( + val tasks: List = emptyList(), + val downloadState: DownloadState = DownloadState.Idle, +) + +/** Download state for a task artifact. */ +sealed class DownloadState { + data object Idle : DownloadState() + data class Downloading(val taskId: String, val progress: Float) : DownloadState() + /** Download completed, providing the local file URI for the user to open. */ + data class Downloaded(val taskId: String, val fileUri: android.net.Uri) : DownloadState() + data class Error(val taskId: String, val message: String) : DownloadState() +} + data class SavedItem( val uri: Uri, val fileName: String, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 26a1407..38023ce 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -48,6 +49,9 @@ import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat +import org.fairscan.app.network.ServerEndpoint +import org.fairscan.app.network.tasks.TaskClient +import org.fairscan.app.network.upload.PdfUploadClient import java.io.File import java.io.FileInputStream import java.io.IOException @@ -69,6 +73,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private val fileManager = container.fileManager private val settingsRepository = container.settingsRepository private val logger = container.logger + private val pdfUploadClient = container.pdfUploadClient + private val taskClient = container.taskClient private val _events = MutableSharedFlow() val events = _events.asSharedFlow() @@ -93,6 +99,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private val _uiState = MutableStateFlow(ExportUiState()) val uiState: StateFlow = _uiState.asStateFlow() + // Task management panel + private val _taskPanelState = MutableStateFlow(TaskPanelState()) + val taskPanelState: StateFlow = _taskPanelState.asStateFlow() + + private val activePollingJobs = mutableMapOf() + private var resumedScanKeys: List = emptyList() init { viewModelScope.launch { @@ -397,6 +409,236 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit fileManager.cleanUpOldFiles(thresholdInMillis) } + fun uploadPdfToServer() { + val result = _uiState.value.result ?: return + if (result !is ExportResult.Pdf) return + + viewModelScope.launch { + _uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) } + try { + val endpoint = resolveServerEndpoint() ?: return@launch + + val uploadResult = withContext(Dispatchers.IO) { + pdfUploadClient.uploadPdf(endpoint, result.file) { progress -> + _uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) } + } + } + + _uiState.update { + it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId)) + } + android.util.Log.i("Upload", "PDF uploaded, fileId=${uploadResult.fileId}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("Upload", "Failed to upload PDF", e) + _uiState.update { + it.copy(uploadState = UploadState.Error(e.message ?: "上传失败")) + } + } + } + } + + fun uploadAndProcess(processType: String = "ocrpdf") { + val result = _uiState.value.result ?: return + if (result !is ExportResult.Pdf) return + + viewModelScope.launch { + _uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) } + try { + val endpoint = resolveServerEndpoint() ?: return@launch + + // Step 1: Upload + val uploadResult = withContext(Dispatchers.IO) { + pdfUploadClient.uploadPdf(endpoint, result.file) { progress -> + _uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) } + } + } + + // Step 2: Create processing task + val taskResult = withContext(Dispatchers.IO) { + taskClient.processPdf(endpoint, uploadResult.fileId, processType) + } + + // Step 3: Add task to panel + val remoteTask = RemoteTask( + fileId = uploadResult.fileId, + taskId = taskResult.taskId, + processType = processType, + status = "queued", + progress = 0, + fileName = result.file.name, + ) + _taskPanelState.update { state -> + state.copy(tasks = state.tasks + remoteTask) + } + + // Step 4: Start background polling + startPolling(remoteTask, endpoint) + + _uiState.update { + it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId, taskResult.taskId)) + } + android.util.Log.i("Upload", "PDF uploaded + task created: file=${uploadResult.fileId}, task=${taskResult.taskId}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("Upload", "Failed to upload & process PDF", e) + _uiState.update { + it.copy(uploadState = UploadState.Error(e.message ?: "上传处理失败")) + } + } + } + } + + private fun startPolling(task: RemoteTask, endpoint: ServerEndpoint) { + val job = viewModelScope.launch { + try { + while (true) { + delay(2000) + val taskStatus = withContext(Dispatchers.IO) { + taskClient.getTaskStatus(endpoint, task.taskId) + } + _taskPanelState.update { state -> + val updated = state.tasks.map { t -> + if (t.taskId == task.taskId) { + t.copy( + status = taskStatus.status, + progress = taskStatus.progress, + message = taskStatus.message, + ) + } else t + } + state.copy(tasks = updated) + } + if (taskStatus.status == "completed" || taskStatus.status == "failed") { + activePollingJobs.remove(task.taskId) + return@launch + } + } + } catch (e: CancellationException) { + // Polling cancelled + } catch (e: Exception) { + _taskPanelState.update { state -> + val updated = state.tasks.map { t -> + if (t.taskId == task.taskId) { + t.copy(status = "failed", message = e.message ?: "轮询失败") + } else t + } + state.copy(tasks = updated) + } + activePollingJobs.remove(task.taskId) + } + } + activePollingJobs[task.taskId] = job + } + + fun downloadResult(task: RemoteTask, destDirUri: Uri, context: Context) { + viewModelScope.launch { + _taskPanelState.update { it.copy(downloadState = DownloadState.Downloading(task.taskId, 0f)) } + try { + val endpoint = resolveServerEndpointForTask(task.taskId) ?: return@launch + + // List artifacts to find the preferred one + val artifacts = withContext(Dispatchers.IO) { + taskClient.listArtifacts(endpoint, task.taskId) + } + + // Prefer ZIP for markdown, PDF for ocrpdf + val preferredArtifact = if (task.processType == "markdown") { + artifacts.find { it.fileType == "zip" } ?: artifacts.firstOrNull() + } else { + artifacts.find { it.fileType == "pdf" } ?: artifacts.firstOrNull() + } + + if (preferredArtifact == null) { + _taskPanelState.update { it.copy(downloadState = DownloadState.Error(task.taskId, "没有可下载的产物")) } + return@launch + } + + // Download to a temp file first, then copy to SAF + val tempFile = File(preparationDir, preferredArtifact.fileName) + withContext(Dispatchers.IO) { + taskClient.downloadArtifact(endpoint, preferredArtifact.artifactId, tempFile) { progress -> + _taskPanelState.update { + it.copy(downloadState = DownloadState.Downloading(task.taskId, progress)) + } + } + } + + // Copy to SAF directory + val safFile = withContext(Dispatchers.IO) { + val tree = DocumentFile.fromTreeUri(context, destDirUri) + ?: throw IllegalStateException("Invalid SAF directory") + val target = tree.createFile( + preferredArtifact.fileType.let { + when (it) { + "zip" -> "application/zip" + "pdf" -> "application/pdf" + else -> "text/markdown" + } + }, + preferredArtifact.fileName + ) ?: throw IllegalStateException("Unable to create file in SAF directory") + + context.contentResolver.openOutputStream(target.uri)?.use { output -> + tempFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Failed to open SAF output stream") + + target + } + + // Clean up temp file + tempFile.delete() + + _taskPanelState.update { + it.copy(downloadState = DownloadState.Downloaded(task.taskId, safFile.uri)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("Download", "Failed to download artifact", e) + _taskPanelState.update { + it.copy(downloadState = DownloadState.Error(task.taskId, e.message ?: "下载失败")) + } + } + } + } + + fun resetDownloadState() { + _taskPanelState.update { it.copy(downloadState = DownloadState.Idle) } + } + + private suspend fun resolveServerEndpointForTask(taskId: String): ServerEndpoint? { + val host = settingsRepository.serverHost.first() + val port = settingsRepository.serverPort.first() + if (host.isNullOrBlank()) { + _taskPanelState.update { + it.copy(downloadState = DownloadState.Error(taskId, "未配置服务器地址")) + } + return null + } + return ServerEndpoint(host, port) + } + + private suspend fun resolveServerEndpoint(): ServerEndpoint? { + val host = settingsRepository.serverHost.first() + val port = settingsRepository.serverPort.first() + if (host.isNullOrBlank()) { + _uiState.update { + it.copy(uploadState = UploadState.Error("未配置服务器地址")) + } + return null + } + return ServerEndpoint(host, port) + } + + fun resetUploadState() { + _uiState.update { it.copy(uploadState = UploadState.Idle) } + } + private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? { return if (exportDirUri == null) { null @@ -443,6 +685,10 @@ data class ExportActions( val share: () -> Unit, val save: () -> Unit, val open: (SavedItem) -> Unit, + val uploadToPc: () -> Unit, + val uploadAndProcess: (processType: String) -> Unit, + val downloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> }, + val resetDownloadState: () -> Unit = {}, ) class MissingExportDirPermissionException( diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt index c503a41..585e571 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt @@ -35,6 +35,16 @@ class SettingsRepository(private val context: Context) { private val EXPORT_FORMAT = stringPreferencesKey("export_format") private val EXPORT_QUALITY = stringPreferencesKey("export_quality") + // Network collaboration settings + private val SERVER_HOST = stringPreferencesKey("server_host") + private val SERVER_PORT = stringPreferencesKey("server_port") + private val SERVER_DISPLAY_NAME = stringPreferencesKey("server_display_name") + private val LAST_SELECTED_SERVICE_ID = stringPreferencesKey("last_selected_service_id") + private val STREAM_QUALITY = stringPreferencesKey("stream_quality") + private val POST_PROCESS_MODE = stringPreferencesKey("post_process_mode") + private val AUTO_DOWNLOAD_PROCESSED_RESULT = stringPreferencesKey("auto_download_processed_result") + private val STREAM_FRAME_RATE = stringPreferencesKey("stream_frame_rate") + val defaultColorMode: Flow = context.dataStore.data.map { prefs -> when (prefs[DEFAULT_COLOR_MODE]) { @@ -73,6 +83,61 @@ class SettingsRepository(private val context: Context) { } } + val serverHost: Flow = + context.dataStore.data.map { prefs -> + prefs[SERVER_HOST] + } + + val serverPort: Flow = + context.dataStore.data.map { prefs -> + prefs[SERVER_PORT]?.toIntOrNull() ?: 2026 + } + + val serverDisplayName: Flow = + context.dataStore.data.map { prefs -> + prefs[SERVER_DISPLAY_NAME] + } + + val lastSelectedServiceId: Flow = + context.dataStore.data.map { prefs -> + prefs[LAST_SELECTED_SERVICE_ID] + } + + val streamQuality: Flow = + context.dataStore.data.map { prefs -> + when (prefs[STREAM_QUALITY]) { + "LOW" -> StreamQuality.LOW + "HIGH" -> StreamQuality.HIGH + "BALANCED", null -> StreamQuality.BALANCED + else -> StreamQuality.BALANCED + } + } + + val postProcessMode: Flow = + context.dataStore.data.map { prefs -> + when (prefs[POST_PROCESS_MODE]) { + "MARKDOWN" -> PostProcessMode.MARKDOWN + "OCRPDF", null -> PostProcessMode.OCRPDF + else -> PostProcessMode.OCRPDF + } + } + + val autoDownloadProcessedResult: Flow = + context.dataStore.data.map { prefs -> + prefs[AUTO_DOWNLOAD_PROCESSED_RESULT]?.toBoolean() ?: false + } + + val streamFrameRate: Flow = + context.dataStore.data.map { prefs -> + when (prefs[STREAM_FRAME_RATE]) { + "UNLIMITED" -> StreamFrameRate.UNLIMITED + "FPS_15" -> StreamFrameRate.FPS_15 + "FPS_5" -> StreamFrameRate.FPS_5 + "FPS_10", null -> StreamFrameRate.FPS_10 + else -> StreamFrameRate.FPS_10 + } + } + suspend fun setDefaultColorMode(mode: DefaultColorMode) { context.dataStore.edit { prefs -> prefs[DEFAULT_COLOR_MODE] = mode.name @@ -100,6 +165,66 @@ class SettingsRepository(private val context: Context) { prefs[EXPORT_QUALITY] = quality.name } } + + suspend fun setServerHost(host: String?) { + context.dataStore.edit { prefs -> + if (host == null) { + prefs.remove(SERVER_HOST) + } else { + prefs[SERVER_HOST] = host + } + } + } + + suspend fun setServerPort(port: Int) { + context.dataStore.edit { prefs -> + prefs[SERVER_PORT] = port.toString() + } + } + + suspend fun setServerDisplayName(name: String?) { + context.dataStore.edit { prefs -> + if (name == null) { + prefs.remove(SERVER_DISPLAY_NAME) + } else { + prefs[SERVER_DISPLAY_NAME] = name + } + } + } + + suspend fun setLastSelectedServiceId(id: String?) { + context.dataStore.edit { prefs -> + if (id == null) { + prefs.remove(LAST_SELECTED_SERVICE_ID) + } else { + prefs[LAST_SELECTED_SERVICE_ID] = id + } + } + } + + suspend fun setStreamQuality(quality: StreamQuality) { + context.dataStore.edit { prefs -> + prefs[STREAM_QUALITY] = quality.name + } + } + + suspend fun setPostProcessMode(mode: PostProcessMode) { + context.dataStore.edit { prefs -> + prefs[POST_PROCESS_MODE] = mode.name + } + } + + suspend fun setAutoDownloadProcessedResult(enabled: Boolean) { + context.dataStore.edit { prefs -> + prefs[AUTO_DOWNLOAD_PROCESSED_RESULT] = enabled.toString() + } + } + + suspend fun setStreamFrameRate(rate: StreamFrameRate) { + context.dataStore.edit { prefs -> + prefs[STREAM_FRAME_RATE] = rate.name + } + } } enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) { @@ -112,3 +237,28 @@ enum class ExportFormat(val mimeType: String) { PDF("application/pdf"), JPEG("image/jpeg"), } + +enum class StreamQuality { + LOW, + BALANCED, + HIGH, +} + +enum class PostProcessMode { + MARKDOWN, + OCRPDF, +} + +enum class StreamFrameRate(val labelRes: Int, val uiLabel: String) { + UNLIMITED(0, "无限制"), + FPS_15(0, "15 fps"), + FPS_10(0, "10 fps"), + FPS_5(0, "5 fps"), +} + +val StreamFrameRate.intervalMs: Long? get() = when (this) { + StreamFrameRate.UNLIMITED -> null + StreamFrameRate.FPS_15 -> 66L + StreamFrameRate.FPS_10 -> 100L + StreamFrameRate.FPS_5 -> 200L +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt index 099a597..71525e0 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt @@ -30,21 +30,29 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.fairscan.app.R @@ -61,6 +69,14 @@ fun SettingsScreen( onResetExportDirClick: () -> Unit, onExportFormatChanged: (ExportFormat) -> Unit, onExportQualityChanged: (ExportQuality) -> Unit, + onServerHostChanged: (String?) -> Unit, + onServerPortChanged: (Int) -> Unit, + onStreamQualityChanged: (StreamQuality) -> Unit, + onPostProcessModeChanged: (PostProcessMode) -> Unit, + onAutoDownloadChanged: (Boolean) -> Unit, + onStreamFrameRateChanged: (StreamFrameRate) -> Unit, + onScanNetworkHostsClick: () -> Unit, + onTestConnectionClick: () -> Unit, onBack: () -> Unit, ) { BackHandler { onBack() } @@ -79,6 +95,14 @@ fun SettingsScreen( onResetExportDirClick, onExportFormatChanged, onExportQualityChanged, + onServerHostChanged, + onServerPortChanged, + onStreamQualityChanged, + onPostProcessModeChanged, + onAutoDownloadChanged, + onStreamFrameRateChanged, + onScanNetworkHostsClick, + onTestConnectionClick, modifier = Modifier.padding(paddingValues)) } } @@ -91,6 +115,14 @@ private fun SettingsContent( onResetExportDirClick: () -> Unit, onExportFormatChanged: (ExportFormat) -> Unit, onExportQualityChanged: (ExportQuality) -> Unit, + onServerHostChanged: (String?) -> Unit, + onServerPortChanged: (Int) -> Unit, + onStreamQualityChanged: (StreamQuality) -> Unit, + onPostProcessModeChanged: (PostProcessMode) -> Unit, + onAutoDownloadChanged: (Boolean) -> Unit, + onStreamFrameRateChanged: (StreamFrameRate) -> Unit, + onScanNetworkHostsClick: () -> Unit, + onTestConnectionClick: () -> Unit, modifier: Modifier = Modifier, ) { val (folderLabel, folderLabelColor) = when { @@ -170,6 +202,157 @@ private fun SettingsContent( label = { t -> t.name}, selectedValue = uiState.exportFormat ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + Text(stringResource(R.string.settings_section_network), style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + // Server configuration + Column { + Text("PC 服务器设置", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + var hostInput by remember { mutableStateOf(uiState.serverHost ?: "") } + LaunchedEffect(uiState.serverHost) { + hostInput = uiState.serverHost ?: "" + } + OutlinedTextField( + value = hostInput, + onValueChange = { + hostInput = it + onServerHostChanged(it.ifEmpty { null }) + }, + label = { Text("主机地址") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + singleLine = true + ) + + var portInput by remember { mutableStateOf(uiState.serverPort.toString()) } + LaunchedEffect(uiState.serverPort) { + portInput = uiState.serverPort.toString() + } + OutlinedTextField( + value = portInput, + onValueChange = { newValue -> + portInput = newValue + newValue.toIntOrNull()?.let { onServerPortChanged(it) } + }, + label = { Text("端口") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + singleLine = true + ) + + if (uiState.serverDisplayName != null) { + Text( + "已连接: ${uiState.serverDisplayName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onScanNetworkHostsClick, + modifier = Modifier.weight(1f) + ) { + Text("扫描主机") + } + OutlinedButton( + onClick = onTestConnectionClick, + modifier = Modifier.weight(1f) + ) { + Text("测试连接") + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Stream quality + RadioButtonGroup( + R.string.stream_quality, + StreamQuality.entries, + onClick = onStreamQualityChanged, + label = { t -> when (t) { + StreamQuality.LOW -> "低 (640p, 45%, 8-12fps)" + StreamQuality.BALANCED -> "均衡 (960p, 60%, 6-10fps)" + StreamQuality.HIGH -> "高 (1280p, 75%, 5-8fps)" + } }, + selectedValue = uiState.streamQuality + ) + + Spacer(Modifier.height(16.dp)) + + // Post process mode + RadioButtonGroup( + R.string.post_process_mode, + PostProcessMode.entries, + onClick = onPostProcessModeChanged, + label = { t -> when (t) { + PostProcessMode.MARKDOWN -> "Markdown (MinerU)" + PostProcessMode.OCRPDF -> "OCR PDF (OCRmyPDF)" + } }, + selectedValue = uiState.postProcessMode + ) + + Spacer(Modifier.height(16.dp)) + + // Stream frame rate control + Text("图传帧率", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + StreamFrameRate.entries.forEach { rate -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onStreamFrameRateChanged(rate) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = uiState.streamFrameRate == rate, + onClick = null, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp) + ) + val desc = when (rate) { + StreamFrameRate.UNLIMITED -> "无限制(每帧都发)" + StreamFrameRate.FPS_15 -> "15 fps(66ms 间隔)" + StreamFrameRate.FPS_10 -> "10 fps(100ms 间隔)" + StreamFrameRate.FPS_5 -> "5 fps(200ms 间隔)" + } + Text(desc, style = MaterialTheme.typography.bodyMedium) + } + } + + Spacer(Modifier.height(16.dp)) + + // Auto download + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAutoDownloadChanged(!uiState.autoDownloadProcessedResult) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("自动下载处理结果") + Checkbox( + checked = uiState.autoDownloadProcessedResult, + onCheckedChange = { onAutoDownloadChanged(it) } + ) + } } } @@ -267,6 +450,14 @@ fun SettingsScreenPreview(uiState: SettingsUiState) { onResetExportDirClick = {}, onExportFormatChanged = {}, onExportQualityChanged = {}, + onServerHostChanged = {}, + onServerPortChanged = {}, + onStreamQualityChanged = {}, + onPostProcessModeChanged = {}, + onAutoDownloadChanged = {}, + onStreamFrameRateChanged = {}, + onScanNetworkHostsClick = {}, + onTestConnectionClick = {}, onBack = {} ) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt index 40aba04..6165f8c 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt @@ -32,6 +32,15 @@ data class SettingsUiState( val exportDirName: String? = null, val exportFormat: ExportFormat = ExportFormat.PDF, val exportQuality: ExportQuality = ExportQuality.BALANCED, + // Network collaboration settings + val serverHost: String? = null, + val serverPort: Int = 2026, + val serverDisplayName: String? = null, + val lastSelectedServiceId: String? = null, + val streamQuality: StreamQuality = StreamQuality.BALANCED, + val postProcessMode: PostProcessMode = PostProcessMode.OCRPDF, + val autoDownloadProcessedResult: Boolean = false, + val streamFrameRate: StreamFrameRate = StreamFrameRate.FPS_10, ) class SettingsViewModel(container: AppContainer) : ViewModel() { @@ -47,13 +56,29 @@ class SettingsViewModel(container: AppContainer) : ViewModel() { dirName, repo.exportFormat, repo.exportQuality, - ) { colorMode, uri, name, format, quality -> + repo.serverHost, + repo.serverPort, + repo.serverDisplayName, + repo.lastSelectedServiceId, + repo.streamQuality, + repo.postProcessMode, + repo.autoDownloadProcessedResult, + repo.streamFrameRate, + ) { values: Array -> SettingsUiState( - defaultColorMode = colorMode, - exportDirUri = uri, - exportDirName = name, - exportFormat = format, - exportQuality = quality, + defaultColorMode = values[0] as DefaultColorMode, + exportDirUri = values[1] as String?, + exportDirName = values[2] as String?, + exportFormat = values[3] as ExportFormat, + exportQuality = values[4] as ExportQuality, + serverHost = values[5] as String?, + serverPort = values[6] as Int, + serverDisplayName = values[7] as String?, + lastSelectedServiceId = values[8] as String?, + streamQuality = values[9] as StreamQuality, + postProcessMode = values[10] as PostProcessMode, + autoDownloadProcessedResult = values[11] as Boolean, + streamFrameRate = values[12] as StreamFrameRate, ) }.stateIn( viewModelScope, @@ -92,4 +117,52 @@ class SettingsViewModel(container: AppContainer) : ViewModel() { _dirName.value = uri?.let { repo.resolveExportDirName(it) } } } + + fun setServerHost(host: String?) { + viewModelScope.launch { + repo.setServerHost(host) + } + } + + fun setServerPort(port: Int) { + viewModelScope.launch { + repo.setServerPort(port) + } + } + + fun setServerDisplayName(name: String?) { + viewModelScope.launch { + repo.setServerDisplayName(name) + } + } + + fun setLastSelectedServiceId(id: String?) { + viewModelScope.launch { + repo.setLastSelectedServiceId(id) + } + } + + fun setStreamQuality(quality: StreamQuality) { + viewModelScope.launch { + repo.setStreamQuality(quality) + } + } + + fun setPostProcessMode(mode: PostProcessMode) { + viewModelScope.launch { + repo.setPostProcessMode(mode) + } + } + + fun setAutoDownloadProcessedResult(enabled: Boolean) { + viewModelScope.launch { + repo.setAutoDownloadProcessedResult(enabled) + } + } + + fun setStreamFrameRate(rate: StreamFrameRate) { + viewModelScope.launch { + repo.setStreamFrameRate(rate) + } + } } diff --git a/app/src/main/res/drawable/icon.png b/app/src/main/res/drawable/icon.png index 842543e35f5c3087332bf1bff8d1430426bb316f..97316bd76e26c41fc3f7b56c117ab78f716c335a 100644 GIT binary patch literal 69366 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

c^1 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 literal 21602 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 - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 1413a31..45b840d 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..085c1e3de1ddd93ae4b817f64cc74ad10cc5c59e GIT binary patch literal 2974 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 O(f9gw*PSEXJ*5D?1<+Lh diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..1e176bef529b55db4e6a37c7f55455903a253d87 GIT binary patch literal 1975 zcmV;o2T1sdP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2TVysK~!i%?O6$I zR8&55UAp2r=baW`g=@*&;nwpwWQ&WT5+S;>9_Sw+T0I%1p z3@IQ~0^Dx5FjQApHy{VQLj1haW_5Kn+S=NbVFiRrfYa&hR{*b0dwaX!sgozs+}wP| z(xCoUX!EzAF z20?zlQrKg=nXaS4PfJUSSe0PVM?hz1XP@lzcbChBeJARW(8bP zoi*~X`b9ti{Mp*tiv7+yjPEG{;UEZmH5~t!Wcvp(uB#YT9+y0{fCwmnYti9sMobrH z=4=G-rz9c)WpKkI4^I;T1@QN!Eq{S(?(qQbaV&7FqCqS}aJ8)*<}1 zNuc#lfd2T<*CYaoD1&|I1QdWe_w;7JITs@;zJ|$egT-J}2X8jRVlu&Kx4~vGVsyq# zP-Y4!?`_{oDD9sO0ZqA5*AdzO1vkEsfux8S?d+S(uq2u=dhsm8Pnm?MJEwrwE&x3< z8&vgefR}s71QdeYzxI~SiJcg^|5Ne8iWgTIyxAkh zyb9%CYLwED2q*-#@9xe1sh@D!o)1L7=Mqr{?x)q5h^X6d#fZ#Q(5p{EZ!}`!x+Ne~ zf!mCfg9Tc&@bD z|Mnhe(98!-+43w?2o*mF)w|3Ht3~=pjGoh%ThoGJ|DaQ z1|RMXZvP0ZSOI>CPvwc$Ibr#HBu$JIBZ4x>{%B7yV8R2_K`$=?t)35B^|-VOb=!Pr zh(QWqSUsiM0t9)(*@saFzJkeWfjvAb=$Du?Z!(*4$hKmsE}jcpOdM=Mv{J798)n`mHZ&1Dlk30g8;x-y#A#Fxp2G(|5AfNQ zYqk_u?EMVU^B#shA{w^Xcvav`ZlOk-6~j5>FZ#02!GFBcS31h6G{p0O9DEWA?nK69 z$Glp_xN_T@h`aAjQC3!+zRzfpq|@V?NACr#%jm8B=TbrcY}Uv=GsF{_9DEiH{!A-315G6@&+j>`ia3*gSPaczL$nIWEu<>1?ZE5))Z z=aXQ_<3I&2YW)@C-_JtC{nK&%JyS89%f(+hym75amo5M_eH39VLcEnwg70NQyBAWD zTX6-bZ8s?A4bYkeprv!9%rBV*+VQ%^1j>}|h1Phhl_9CnuK>5#BW}nBe>2BIN8`TEP@iiaH9unP>(Q_4axrp*e=^=KcOIK0e(2qjg4L>GVl+k zdn=KI3bEaXN`8)?XFF_BT=I4CeioPziwrG zAJmxRDt=1+CeZ1blVX%YUp585H%RHquqYC@OKqZfv7+bL;-@B-H1lIX%rv?Q02b> zodxg|_(f;1f*p8Yzz{g-ze$4FRt7tk^1qhuhFN}p>#*nCJYx4f zV;A42m;MJGtn9un3)jVhQ5MPHLDt{9@ALfaSc6FiItP~BuFmHk8`pC`9pEX)XCcPA z9Vf{pKdV!Ie@`C%{?_+rN0-&|xJZ;jB1mxj_{mNIM0UgKIHi*pV3QtKarnBmYgVsZv3%Lm zRgoAVI;6Yt9NC8WEy0mbUcY(!{=>)5U%tM(tHJ=V*ng;Of;WaRb?3%5)>hgLYO7Qj z0K5ez+tg0R($@c~RcvNWz_6VP9BV7XFpQZhoAy>ZXz}S97FUYd_v`F5bf!OUFx#SW zGHz-i%8kMF=S^mtHBPSm`ipWmW7A)^l;GC8r)Vz^WB6UcY?H>xy>A}?ur$_IhReV~ zoDXOs@`v5V8Ykn1od6mcD~VbGDn(5nDNo$^d9n%K7((mvw*p`B9LyjNht_my*1Tn_ z)@|ChYu}-x^S9f0gHM2LBOF3-#GO|!Up#;IRQ>qT!w2{8RjyV<5GO5Gq3OOZK#LuL~ zW+Wss<{2^BXHlsZCweE6Vq!UC;K|4J2@=!2bSyVBE0bwfX3o*ex)Pk|8(lbeo-tfB W(kDoolp7c_=j8-ZmmXlwQGE$lCj6TK diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..1e176bef529b55db4e6a37c7f55455903a253d87 GIT binary patch literal 1975 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 ack4 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index c42de14276f47ee4aa323188f79b029d71306f0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2272 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 ack4 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 17cae6d5cb33d84b6005d56a6d8f8cc137608b63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4788 zcmV;l5=-q;Nk&Gj5&!^KMM6+kP&iDV5&!@%U%(d->QK1^vm~B#2Bjwr8iT=QUyHX6KW}AwtXoy6>(~~;f_8- z0bGMqEMP)YuFM6@QUSaRFc;vMnHeIc)CI`djm^w#ha{-Nj646#>>8Arna(mhWNbj; zEy9c!W{oL3HQe0H3~ zS$el<06mT#x-3YNP1`!QeQn#eZQHi-Y}>YNzyE97wte>*2X5O&l04m{aj(61zd$$t ze`S^uZQq+AHIVDbb$55acV@2pxV+;t?+h|?&Y5%GDTFo%-7R&p`#f*9Tf|0G;44!K zoopj$8N@e3Q##O4t8i`MHiG011!!ia&;SJxSw<1=Pzn!4k@HXmM4RE+o+>uq4A&Yc zgAN?R%_xC~5;mh8vdRA`4crk006g}BO6$`!b)HyV9Y2WB#iMTSXqC8&{a71+>Ij~*{J`9 z&iEA+q&#KiByo;qlwC&I2@Wn*$}SOyN^y?B8G4rY?LL9?eD2HGFR#e@wV#z;&Pu6L z2BU}-mnvkGD&;Jk{OEtVCsF+XBY%A>vGNit7YHt7m)k;~7;ED~3ckssZw262<=zBf zjITd-kUY%FMS@EO7g8juP;e;)@A-*m0r+LDz8fG&31?*=!NH{zSyU=m!P_wXiU4@G zd_|uLqxM(G%I5@cY$&qH*jORzTfy|nwHpAFFVE)_C0LPFjv_0ELjg>=*03vk9MwXK z%qp}o#y0(41km7@rJ2kJ9}>*ycN8~hY6aUrP*%PMt~=q&?1&~M7D`m4q2@^;7zhCFa$VjV>6p>zULcK>0-qR(*z}OmV!!C{Zm^Z^ z1oJjZB_h$~cJY6F0W9(FPnnt>ixLI1;O`$m6CnV{?zw8S5G6Vac0V|`4pJreg?Ecw zAyl)Zjn(hi6v&wJH3Aq_6%%c`FrpOVQYfzBdwv5Dfohvq#aOOXl_O@)p1 z%x=G)RH5hPI==$|&eL0&3>?@Pl1m54s$)e26Vm>W$;cjjPu^L3i~9h8b@fOF4TBsl z6$h(PR;NSLin4Zfi+oxJnOuR0N@O~$Exein6$w9-McX&nhvI?h5}H?cknozEPDSUr zc>*w*%Oxg9rQ;Kfxn;41mY~iWT&$8`BF0oKZ}K*PZSw?vaYpXu(L<_EP9lIdUB;YW zUhzxVCJ`RV1i)zTf)qj`L;(W}=eF=Nj*KRb%w~T$vYNf%$ZGMqkliAhklpGtA*U6Z zBc&cU`sS(uXJPUdUn z8D$o0!d!-`6p-TFzjX{K3*J7uh?@fnkvA_R({SdNbHlt!9n$iF7xr`O_7Fo6MU|iw z0|*>nailijMxQ)o+(MtyQdo(6C~4B8g0>H$plEib4c}87RYEc>AaHuk9#@T9f{K)I z@KL*`lA&d17d&vlC*M3ohvZNIO=>GtwXh5m@b8|oC)Qf`Vn5KbD`JwPHN-!WJ(M)8C4^yzzF<1r|i)cxFxViF*~1( zRxD@KmDnVUkwYfYNQRp6zHeI|x`0^T2R6-W73}QORU@Xv3Va)f>~U3ipM_rvHik@! zyc?@3jz)n5-fTUW?usK)sufpamhgU>MF3F@TJ;iY?8L_xTR=yasp({h=_=Wj zNktP)lbjC9v@l0okf$p%UCg>mi#adO;MOmoXQHK%3@xX7c%QX7=)=E54ZE@+fhKu9 zlr}%t{uJpa#}O1Tu+Ym?v)IddHq_~ap`m~rSsGcNLZ*< z#RSz?TdgZA10hXL7UA?*OlJQAMa&%Z7fPFasLeT4_U;7W(`#j!le?}_08LDq`WHe%w$}Mz>bSf=HhhK-CdoWUKv!#nYx{hVs3=IK;fX2v0aZ z6oLU_D`MrMe_Tqm>3;+`dK7?h&?X9p04-z7dlI-%iXm4Qv+_`~j@={n2G0?o|4;>h zSB0jC-ir$rawC?YBE@Vxl;&lnI9F>Jeg`K*Uj(H=@BZFkCg??`% zrCbyq2%PWDGxUDmjcm%C_@z2DZKSZpZ`9XH-(sPo=i25BoE!CU1o$~?YsAy3Wh05_ zs9#qkN!o;f)Zv?eQv+~GM1Y+(`3)?ZPHj6WSVM}Hatcri(8SM; zc!zOVIfqi0j-3>2AtflyC0ScR-}9OMxt>uZF4H1FZtXqR%-wp}MR(C7O`e4?8eIn| z@KP=z?A7vJvCgE(WUTSCZ&>1oz*W6m5)lxjZT?JIk0h0t%s9OzA^!hs_9=nV=s8J8 zg@M){S?fOk+lP+WdXuF8y3?nKy2JMf2qS?<7d#LUU=4Nqu@?D#0RZ%t4wyt}^_`@X zLCs)WKL%jmIkSkkpScElo7c>bIUHU$w0dTgvRSn$_jEyD`JlZKS_2p9yy85$0pPxR zFhP9KOVp&R8m2Wue+Ybaz&>S-I2#_bj6%Zfhte6kNJu!oMmFzXve&|3+rGHQ)d2*$ z2r$|{XQnx{lUNgGpk~DD2%V7|=l=CWxG(~ih_vn-*r}yTF7eQo!;P`*$~Y;z0Nza#yhscGrG((yvQ>+&)ql2-7~}8HO1XA$=x>2)iTD_ zG{RLs%vn3cSv|m6*~eMlODOFo6m<~_ItY2~9NDdo#s06eS$I6RPXO$@=kUW;p!m?Y z_h9K)P!Q4+F4FpQVwtUWN+3+QFYf`|`#WOy9XWeFCSK|Icu~1L65{_q0K?ci{Gu93 zRz1_qbKr21HaR7MAhZEC=?F+i+tHc1_(6?}Gt@i@02JGA`l_**q1rYrCRb>)Hct(q#q^DZu4L6De7tmJNr2nDc zX=LVX4TP7@vMe6}faSF|nX*~<(#WaQxOggTtk+eT7?MaOA$xgFsW=WB1s+h^&{jHGt>08+wZ@>uY5>LU^zB5 zmT4}FrXO818vwiyFN$Oh@F&ch54ZhKCCvzM32lab3Id|KSvE=4yx&@$_s6)(Bw#t< z&2DhX!{USB?=Qd$e0*v2D6kZrm$%|49;|~Uizhe27L9^y>5Z|%MnJaP^Aw+@j6Q|Yoh0sh4~kFU+gZljUZ(L0@<`?SSh=>;)5ydD+>2q+KP z-pMZ!{NgXo@2?(lj0pJ`5rTro2bQUEab$)nbUn~T!hQ3oBd)=dGCn9{OXznS=Pz^O z;UEfHDlMWh?8D5>x@4jpc5<6&maGuE61_9e;+;E4*w#c`-jza5G!x(tgsg^59qH9 zjE%TXbN{|{CNN@^2aFcKnHNMuBkE<5kAVCP0HUk_j2V|suZecT`UL@P((m) zxb45pR0CvrH(b{8S=`_i=FLbOSb7B=g)=C+@ z-PeAcU-s00c9FjWPZ zH8LxYfJV*Fn)R^Td!N1qTqs~yc4bSfdj_m@EntZ4e5~nNwJmtVDgn#cq?o)2P-|vR z1_71GZuY4#-bS5SS^*Advb;s+)dFP4I2(3#y^qC_AwTwbDaa%p0aEoW2&-U52?5Q@ z&z$*y+q#@2>ExF%E5)xZZb^NXuE(+=ce{bmFo{N$tCU544a_Pa zpcYy0Y`gZijSF!Swf*d~m;?;XnAZ}VYFYCdnh_x6+Gk&;zx}*e&uqCzhRuG~a-3oE zCJ~-WSP<60ielKZ<((lw|1aH@_btByj9&?|ILAYMNu*YmW^OLafie zEm4xe@9kIKnKD~8?C>jjA}F{)Is$l4_zgo|T;7?|tL0gB=wj~+huH4pSPAP=bTX5% z2Kmyp_pmVG2wNBW(2SXCkQCL^aT3+-bu;N9hPk~L(O3P8m$)HG#=Xfi8B5)4Doyj*C2sojgLe4-Ft_(S ztjoR6efu=GJ`VzFPrKlHp&~Mww?lx40PU=ITkA3hoF}rpGo@eNnKIhHx@Xb0zS(wZ zeD-~seUJPuwa~Y#dzNhWzhtq<^Bi!QweEJ-&lT`eaI>f$QNU9LU{}Y2=*KsFTFH>G z0}d!yu;2kTijG% zZMt=YFAO!5393igwr>NVi;}Jq0l`NM#a}z(+Zg1dY4(DEfV%5HM%d?F>PSGqr1@A? z>1nX_o)yUdj&aKu!ucS54Gj$gC>8-@jf~CT`byBHl&tGR$jnaCqvXp-o8rry(FJC{&aM1t_|Km|XFj_> zd#Bb?OT!KBsQ-KX#!!g9-^Xf$xTTik@5KI6$5I=Q5@ZM{ARypZ^#o;LW8=M;nv3QE zOyh7Z>mc#c8+~V|RI;b0rdxFVFtQYgn-p?>rcZ7;Egh}HS2zt65EOh-g;Dt`DiIf- z*WYihma7s&JwO^LjD7J{^o6c&LP&*2nh7o6RHx)FM^8`BgvT=zlR*Y83t&#R`?7fx z?}7(PiH|`&$x%i&q&P??@KU3gY$H^%H{|%f0C;>Zk zU?u+V)QWR{vNW4iXSjdoTi^;%%4Vxt(yW2wnRGzu@W7%2Zl08<=BB71!NAq>DApp* z*W6U$of*b7_wc^N&-oC#A#BgTQq{AJX1}|b9*Yhg`1&@yYS!q7F zkd(JC=$qouE_9`yQWBB3e>XMK2Wc&=E|olC%((g%p^#1|hTM8*Op99LHm{%m)Xrqk z2+A%DH9t^6i}+Wj$+H-w-3w4(2(jRzi#>7`LoBq{D-=-_|TI3hQq0dr>Rc<$$Mv{6!@Yu=9A?GNx*0I zowxFZA_)jkpqhLskTYZpaGuOyXS+udZ5QVrAOthIC7L%f;ojBocil^ZlY|5&GjJAp zu>+;A^igp*ySl0Evm``mdAVCSP+}gpKf%d8U3FTxQ9Wcxx+6g|5a6C@V{N}EVZp-( zw8uWPHhJUjMm4mSq@h?kInvbN)gChB0ht4F8=d|z*C{tWK!*%d1m)4$&BVIScr8lL zb(;Xj13sD@nGZ1dCwmQ1rSEAJPn~M;NqA=q+Y2I1B?pCnMAcD?>)soV3$UZX_&k!)e{xwSNhvI{h_!t#5a!w` z0x}pOTEQs88+O$ZJUE3}{vz~WB|9BDv{t{)d&*c2xz258#(X&?H*)Ey#AXUIXuD(o zoe|v10RQ#=pc8QI(qp5{cF~Yc%g^}idGm(nQ{F*PcVJH}VotHkR&O?1XJO*k8TO6i z5iuP)hu@6Ex3=B1P^X!3w_f1HM1b}uaFy*KS%74;@+zH?p4@SJ9p)Q&1x*lT;uf@^ zHn>4NV9Flpy8c1^pX4Iok4A3}@-v3q(01`PcWcesM3Apfk(cpb9*oR!M+Dy=y8Kr> zz^x>7gJvOHw7dHl!KtoE5pY$uGZyiHZT*V`EH+iTSN30os-QW*AmeSV)rqe?&O$a| z+cN8NT2S^Mo)~QU6TSE9_~<|mqO<*?7DpmeoDBE3sXO;R%laTD$~n^?S$fSYb_`D0 zmi3y9X*w(`LoxePy*%GfSl=?`*64wX^Dxt{y;;k`d{CKDV%XK>Zjm|Z*i-O%Vm)y@ zGUsZROot*}dxQ5W&ZryFWu&!qRK6o}lmqvFcFVXsxrrUCp_vfbL$8;ba`SNK2HEGU z8ioN3U#fGSPWY(OwBEI17(QhiSb3jq>ud4tJ5!aXxOa(@fjX3d<{WYddJNSKD_zSG zYHye{>~&Iil5c5~WaU>ZDEz#gJ!N5@vw>x!O_<%P-V{o4=V?(2og@m7P(%mKoN zTEW1VdTpSRs>^VU)M{G4YkP-B)@zUC=WeW(gJj*KS27Y2&OXe8O;C#b9l@KbC;yaD z6QbiM8!MVr7=`kmN9by3qD29F$aENy??h_I2h%bBvPY6115C@x z+LMi#k-_$-R~l9I4=chS*9hQ5RD80BBk z#H|UHM>IVY>_xmcDEg}+HM98WeEB7l;p>T)Jy`^#XJvI)KU3FYs>qH+2#lwYZ46Y%g;dU*1 zxIVa_GACWqJD+*P8uUXJ@aSR>jF|@<;m5%>kH`#;jIog(Y4C^GqVES6?ugj5{K;aP z#IZ)2Bzq!37IP(Y9kv!~=3M&c8+-C-)V2fc!%bXY(JW#hS{q6)amud}UMG?-PsBKS zS?7f?H@Z(rFmMFYwC6~W%I{X??z^|T&MzZn4vTcO_xL@32X#!DS$97T&g;0tRsK&} zNBc|nk=#LUkRM&r>T0G(lNMIN>PC#*bB=Eb#N>C05}h!jSgtP`D|0Mk-16uN+!6OZJo(WoB*C z|J9}s9(GyZmojV69tc{0P0%Mbn7UHPu?RN|YhxP{^KQ|?ifz8VChC4i@4MRW@bf!M zl|GvYOJIUGKyd?&#$68;qe;@>ckAsf3+#zou0tv>%j-b(567=Bp}&xsr^(l415J*6 zRoJ_=_jtIq8gYrfG*%fIsx-P0!Iw|K#x`?e)5~%Mk?-DRtx zM@0v}lz8>xWK3Nqb&FqU@qs$#wdP%hJKHUkbmD@cH;;RWdmTQ&tzFuNRgZ{YMa88; zSBqit$gtbu#?;itE^Bo=H@4$5SwZ*R>GaYc>b=_c5o2e?h|s@LR_I**BaOP=22fr}qGBfycVO$TH%L&0ZWTN0 zD%TJuc*WYLCW2Ttt(8>oQ&4!m<$|Zb8Suwk6DjclUEJ@(B^Ko>SP;9Qd1lj#7>a17zgcxD0 zzmtKu>`GKgo6xWuN|EAsdjCahy>i8(q2uefW&o#wcl56;au9LjEBFvpdqao9oYSI5 z>sRQdjEptS(elV;Zz%n995(5keITmo=AZ0a$pUq;fbP%=yFdL@69h#1LBAl1#91Bmug_Z*@rPy^d4UAT3kl3J z7}aIJMB&~`&JUQJv% z(8POF7N(uZGYcZ?iO5t}i~~0qzm)TgVUSdDM}Y}7H_yJe1m|EdBYvFgzrYpaH%&D5 zSDVG}D%_zDL)9A<-Vk3#j2H_pgv5YL>+9>$tVSBS1d^gAB|~WieTA7G9C&@I*@*UX z(eCzw-}~3J_nyhX!>;`tpt{<>e-ITXKhl?thI7XufUBoMg#Pn_Cd>6bFHVPOJ?`Fi zB*87z94yI(see-N6YO?*0Udm%>=Aa;uk)*;czg54A{#9i4X2`_lJ12Q-q)SY5VelA zcDQ+d)%1pa*>UyYjZFnjDzh++vqh!y_hzGxJb2gV6Kct2ssET zFzLH-?|XwB-f8(zXG}*Fz-)V_*0^!K>9RjnB5tve5d=oMxw-w~!akCThX|?8EUKZ} zkU>}Vin|U|k$O#`Q;E9x4LJVncXmstHw|z#6B+WcI5DA&3!9_l|Hm(@VG4zK?}eR& zRZQY7sgF?jEp_c&VtXr1ld3sFBwrhDv{reCOtlsjoP7U_1Uut!b6=vdOZeA|63K?_#OcvE>VCb8D=yRv^*Vp3CPTmaO z#yZe%C;74kAKk!cnV}9vin4_{CZzqE?`8K27!w*BC_zcL~q=g_@oOj01+fkSzeNIas!@HH=+UM^;b z+=CNK_~fwPK_rxM2!7cZ-b(747A-R?bT6{K#^}aBYOzJ^T_KOp7xTc?5dIRgd&sXV zK=TNROpqf~`_Zc971g&h5%n5YQm#Eimv>u-KMm^@kft2HkFziv1Jh?>PnVL0HC(Y| zju8y55cxf`Nb7pGZFRrhen+Rzq3mIfbzlG+74TK>k! z0k=1#GZfw1E-h0Xg%3Flb3cLNVtP~>MBQfKo}Q<(thaY8!UITQ$WjD-U>LF*v&bK_ z3dghSQk3TCssN+B}MQ{^-qdtl||zqKF$m%Sv5Y zML%IF>R=M-H+Jt;R2U3n6MmeFi+SDkO9o=j5!1F`#JBZC%R)?;Nj$eMg=Az5zr%2r9ym%Cmdpe!Ms8H*^$(v0?Crorf@qIkum7QMj zUeF8M-rmXV-Eq5J7ukcn$0!Wu9kQV5o4gl4ka1e!7Y1e%kQOw2QVCa}#-h_j z#EgfI&-iX;1Z(%DA&|_b)HpZeTd7895Hv+VrC0=bnrYi==jbsj>-uM?$^SQ)`+V#5 z6oGZ`&|{Tdok1x~x^QL{Rto}ga0eaLf zXE65gL{8dlPlwsi=gBXy1(|BIVZ(jp1)M%1uHFweIO)5?idl)IB^tjzei}{F#I{g< zfT8eex?3_YMYlR`p^$oypivF$(e~(L{IfRG=BpC9^;L3Ve4FB6w=_UF8Szo~A;XkO zUdg}X)At;YsE)>huakTh5t=}{gWxF7w>+W(9%gKy{Z7XBKS;_OXVjruNxixCil#8{ zU=iTbAL8x@lBdH1O+#Y(Vn%DkSq*JJ(54vfN=ffH!;dL&!FoUc?q2D;6jG!ltrf6_ zSL8>RDle(}n&xIX#QL4P{Hv@)(;Rlb&<=f;IBDR6Sb$WS%fwj=1nW*byYm1YOggP~ zFu$dki#~rv_VX`yIvFpP{>X*s^u4XmZdr_Gtyee+|T|T zPA2lYxl6;6OmU{n5w}L;NUdDA`?)o}mGP?bshW;5r9%q8r=|0>+{G#uOh5uizMrLO z6w|_qX0#&aF(X?s3d#H*qOr+qOfaw?CQo!68(qD>_b1Dok@F6T0?d+37OMujR$yqkqW-L%f1J%K+DARlmK$LqFtZ-q`-1GdLd6%+UA z^t?UwW_yS$0S#pX&tb-&UzS98x{|6s(>=!F=`*_o6VI-TR>cWk zvUP2(@d$;f)dy98IDig$-^%Th-8@*q)c+S)f8<_a7Dx#m S3%&hlAb6~%r&_IS8~Hy(q4q-n literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index dc21c1e7bede2e6d9622e5da1770c96d6734021c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3342 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 YrvftPXqB9+s!TyuTijG% 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 S3%&hlAb6~%r&_IS8~Hy(q4q-n literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index efc7ae4474148c11cd23c819c573e30b26b92811..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7312 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 LuO?S6V;cM)PzPbf literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index f81c04f945e0f16cc7aa449308c110fc05fcc15b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4630 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 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..6dc4dcec7bf2d59613c4f85eed748dd43fec5dcb GIT binary patch literal 8563 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 LuO?S6V;cM)PzPbf literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index a1bec3a974b88d2370fb1d2d36ce7e6a466a94b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9776 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 KMo1(EVg>*M5u7Oi diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index 9aafe68..c5d5899 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #0CAD55 + #FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7685a25..1e0793a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,9 @@ Settings Scan Export + Network Collaboration + Stream Quality + Post Process Mode Share Share document Cannot save file: permission was denied diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..2439f15 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a27a38..6f7cdc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kotlinSerialization = "1.10.0" reorderable = "3.0.0" jetbrainsKotlinJvm = "2.3.10" coroutines-test = "1.10.2" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,6 +64,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/pc-server/README.md b/pc-server/README.md new file mode 100644 index 0000000..f095aa7 --- /dev/null +++ b/pc-server/README.md @@ -0,0 +1,15 @@ +# PC Server for FairScan real-time camera streaming +# +# This is a minimal test server that: +# - Receives WebSocket frames from the Android app +# - Displays them in a browser +# - Provides GET /health for connection testing + +## Quick Start + +```bash +pip install -r requirements.txt +python main.py +``` + +Open http://localhost:2026 in a browser to see the stream. diff --git a/pc-server/main.py b/pc-server/main.py new file mode 100644 index 0000000..60ae0e2 --- /dev/null +++ b/pc-server/main.py @@ -0,0 +1,800 @@ +""" +FairScan PC Server — Streaming, PDF upload & real MinerU task processing. + +Endpoints: + Streaming: + GET /health → Health check (used by Android for connection test) + WS /stream → WebSocket endpoint for receiving JPEG frames + GET / → Web page showing the live stream + + Upload & Tasks: + POST /upload/pdf → Upload a PDF file, returns fileId + POST /tasks/process → Create a MinerU processing task (ocrpdf / markdown) + GET /tasks/{taskId} → Query task status (queued/processing/completed/failed) + GET /tasks/{taskId}/artifacts → List result files for a completed task + GET /artifacts/{artifactId}/download → Download a result file + GET /files/{fileId}/download → Download an uploaded file +""" + +import asyncio +import json +import os +import time +import uuid +import zipfile +from datetime import datetime +from pathlib import Path + +# 国内网络环境无法访问 huggingface.co,强制使用本地缓存模型 +os.environ["HF_HUB_OFFLINE"] = "1" +# Tesseract OCR 语言包路径(OCRmyPDF 需要,从 conda 环境自动获取) +_tessdata = Path(os.environ.get("CONDA_PREFIX", "")) / "Library" / "share" / "tessdata" +if _tessdata.exists(): + os.environ["TESSDATA_PREFIX"] = str(_tessdata) + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse + +# ── MinerU & OCRmyPDF integration ──────────────────────────────────────────── + +from mineru.cli.common import aio_do_parse, read_fn +import ocrmypdf +from loguru import logger + +app = FastAPI(title="FairScan PC Server") + +# ── Configuration ───────────────────────────────────────────────────────────── + +UPLOAD_DIR = Path("./uploads") +TASKS_DIR = Path("./tasks") +UPLOAD_DIR.mkdir(exist_ok=True) +TASKS_DIR.mkdir(exist_ok=True) + + +# ── In-memory state (streaming) ────────────────────────────────────────────── + +latest_frame: bytes | None = None +frame_timestamp: float = 0.0 +connected_clients: set[WebSocket] = set() +stream_stats: dict = {"frames_received": 0, "bytes_received": 0, "started_at": None} + + +# ── HTML page with live stream viewer ──────────────────────────────────────── + +STREAM_PAGE = """\ + + + + + FairScan Stream + + + +

+ +
Waiting for stream...
+
Not connected
+ + + +""" + + +# ── Routes: Streaming ──────────────────────────────────────────────────────── + +@app.get("/health") +async def health(): + """Health check endpoint used by Android for connection testing.""" + return JSONResponse({ + "status": "ok", + "name": "FairScan-PC", + "features": ["stream", "upload", "tasks"], + "streamStats": { + "framesReceived": stream_stats["frames_received"], + "uptime": ( + time.time() - stream_stats["started_at"] + if stream_stats["started_at"] else 0 + ), + }, + "timestamp": datetime.utcnow().isoformat(), + }) + + +@app.get("/") +async def index(): + """Serve the live stream viewer page.""" + return HTMLResponse(STREAM_PAGE) + + +@app.websocket("/stream") +async def stream_endpoint(ws: WebSocket): + """WebSocket endpoint that receives JPEG frames from the Android app.""" + await ws.accept() + connected_clients.add(ws) + if stream_stats["started_at"] is None: + stream_stats["started_at"] = time.time() + + try: + frame_count = 0 + while True: + data = await ws.receive_bytes() + global latest_frame, frame_timestamp + latest_frame = data + frame_timestamp = time.time() + stream_stats["frames_received"] += 1 + stream_stats["bytes_received"] += len(data) + frame_count += 1 + if frame_count % 30 == 1: + print(f"[Stream] Received frame #{stream_stats['frames_received']} ({len(data)} bytes)") + # Broadcast to all browser clients + for client in connected_clients: + if client is not ws: + try: + await client.send_bytes(data) + except Exception: + connected_clients.discard(client) + except WebSocketDisconnect: + pass + finally: + connected_clients.discard(ws) + + +# ── Routes: Upload & Tasks ────────────────────────────────────────────────── + +files_db: dict[str, dict] = {} # fileId -> {fileId, fileName, sizeBytes, uploadPath, createdAt} + + +@app.post("/upload/pdf", status_code=201) +async def upload_pdf(file: UploadFile = File(...)): + """Upload a PDF file to the PC (no processing). + + Stores the file in ./uploads/ and returns a fileId for later use. + Processing is a separate step via POST /tasks/process. + """ + if not file.filename or not file.filename.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="Only PDF files are accepted") + + file_id = str(uuid.uuid4()) + timestamp = datetime.utcnow().isoformat() + safe_name = file.filename.replace("..", "").replace("/", "_") + + # Save the uploaded PDF + upload_path = UPLOAD_DIR / f"{file_id}_{safe_name}" + content = await file.read() + upload_path.write_bytes(content) + + # Store file record (pure upload, no task/processing) + file_record = { + "fileId": file_id, + "fileName": safe_name, + "mimeType": "application/pdf", + "sizeBytes": len(content), + "uploadPath": str(upload_path), + "createdAt": timestamp, + } + files_db[file_id] = file_record + + print(f"[Upload] Received {safe_name} ({len(content)} bytes) -> file {file_id}") + return JSONResponse({ + "fileId": file_id, + "fileName": safe_name, + "mimeType": "application/pdf", + "sizeBytes": len(content), + }) + + +@app.post("/tasks/process", status_code=202) +async def create_task(body: dict): + """Create a processing task for an uploaded PDF. + + Request body: {"fileId": "...", "processType": "ocrpdf"|"markdown"} + """ + file_id = body.get("fileId", "") + process_type = body.get("processType", "ocrpdf").lower() + + if not file_id: + raise HTTPException(status_code=400, detail="fileId is required") + if process_type not in ("ocrpdf", "markdown"): + raise HTTPException(status_code=400, detail="processType must be 'ocrpdf' or 'markdown'") + + # Look up the uploaded file + file_record = files_db.get(file_id) + if file_record is None: + raise HTTPException(status_code=404, detail="File not found") + + task_id = str(uuid.uuid4()) + timestamp = datetime.utcnow().isoformat() + + task = { + "taskId": task_id, + "fileId": file_id, + "status": "queued", + "progress": 0, + "processType": process_type, + "fileName": file_record["fileName"], + "createdAt": timestamp, + "updatedAt": timestamp, + "uploadPath": file_record["uploadPath"], + "message": f"Task created (processType={process_type})", + } + tasks_db[task_id] = task + + # Start MinerU processing in background + asyncio.create_task(process_with_mineru(task_id)) + + print(f"[Tasks] Created task {task_id} for file {file_id} (processType={process_type})") + return JSONResponse({ + "taskId": task_id, + "status": "queued", + "processType": process_type, + "fileId": file_id, + }) + + +@app.get("/tasks/{task_id}") +async def get_task_status(task_id: str): + """Get the current status of a processing task.""" + task = tasks_db.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + return JSONResponse({ + "taskId": task["taskId"], + "fileId": task.get("fileId", ""), + "status": task["status"], + "progress": task["progress"], + "processType": task.get("processType", ""), + "fileName": task["fileName"], + "createdAt": task["createdAt"], + "message": task.get("message", ""), + }) + + +@app.get("/tasks/{task_id}/artifacts") +async def list_artifacts(task_id: str): + """List result files for a completed task.""" + task = tasks_db.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + artifacts = artifacts_db.get(task_id, []) + result = [] + for art in artifacts: + result.append({ + "id": art["artifactId"], + "artifactId": art["artifactId"], + "fileName": art["fileName"], + "fileSize": art["fileSize"], + "fileType": art["fileType"], + }) + return JSONResponse(result) + + +@app.get("/artifacts/{artifact_id}/download") +async def download_artifact(artifact_id: str): + """Download a processed artifact file.""" + art = artifacts_map.get(artifact_id) + if art is None: + raise HTTPException(status_code=404, detail="Artifact not found") + + file_path = Path(art["filePath"]) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Artifact file not found on disk") + + file_type = art["fileType"] + if file_type == "pdf": + media_type = "application/pdf" + elif file_type == "zip": + media_type = "application/zip" + else: + media_type = "text/markdown" + return FileResponse( + path=file_path, + filename=art["fileName"], + media_type=media_type, + ) + + +@app.get("/files/{file_id}/download") +async def download_uploaded_file(file_id: str): + """Download an uploaded (unprocessed) PDF file.""" + file_record = files_db.get(file_id) + if file_record is None: + raise HTTPException(status_code=404, detail="File not found") + + file_path = Path(file_record["uploadPath"]) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") + + return FileResponse( + path=file_path, + filename=file_record["fileName"], + media_type="application/pdf", + ) + + +# ── Dashboard page ─────────────────────────────────────────────────────────── + +DASHBOARD_PAGE = """\ + + + + + FairScan Dashboard + + + +
+

📊 FairScan Dashboard

+ +
+ +
+ +
📄 已上传的文件
+ + + + + +
文件名文件 ID大小时间操作
+ +
⚙️ 处理任务
+ + + + + +
文件名任务 ID状态进度处理类型时间操作
+ + + + +""" + + +@app.get("/dashboard") +async def dashboard(): + """Serve the task management dashboard page.""" + return HTMLResponse(DASHBOARD_PAGE) + + +@app.get("/api/dashboard") +async def dashboard_api(): + """JSON endpoint providing dashboard data (files + tasks + stats).""" + # List uploaded files + files_list = [] + for fid, f_rec in files_db.items(): + files_list.append({ + "fileId": fid, + "fileName": f_rec.get("fileName", ""), + "sizeBytes": f_rec.get("sizeBytes", 0), + "createdAt": f_rec.get("createdAt", ""), + }) + files_list.sort(key=lambda f: f.get("createdAt", ""), reverse=True) + + # List tasks + tasks_list = [] + for tid, task in tasks_db.items(): + task_artifacts = artifacts_db.get(tid, []) + artifacts_info = [ + {"id": a["artifactId"], "fileName": a["fileName"]} + for a in task_artifacts + ] + tasks_list.append({ + "taskId": tid, + "fileId": task.get("fileId", ""), + "fileName": task.get("fileName", ""), + "status": task["status"], + "progress": task["progress"], + "processType": task.get("processType", ""), + "createdAt": task.get("createdAt", ""), + "message": task.get("message", ""), + "artifacts": artifacts_info, + }) + tasks_list.sort(key=lambda t: t.get("createdAt", ""), reverse=True) + + total = len(tasks_list) + queued = sum(1 for t in tasks_list if t["status"] == "queued") + processing = sum(1 for t in tasks_list if t["status"] == "processing") + completed = sum(1 for t in tasks_list if t["status"] == "completed") + failed = sum(1 for t in tasks_list if t["status"] == "failed") + + return JSONResponse({ + "stats": {"total": total, "queued": queued, "processing": processing, "completed": completed, "failed": failed}, + "files": files_list, + "tasks": tasks_list, + }) + + +# ── In-memory databases ────────────────────────────────────────────────────── + +tasks_db: dict[str, dict] = {} +artifacts_db: dict[str, list[dict]] = {} +artifacts_map: dict[str, dict] = {} + + +async def process_with_mineru(task_id: str): + """Process a PDF using real MinerU pipeline (replaces simulate_processing).""" + task = tasks_db.get(task_id) + if task is None: + return + + process_type = task.get("processType", "ocrpdf") + upload_path_src = task.get("uploadPath") + file_name = task.get("fileName", "document.pdf") + base_name = Path(file_name).stem + lang = task.get("options", {}).get("lang", "ch") + + if not upload_path_src or not Path(upload_path_src).exists(): + task["status"] = "failed" + task["message"] = "Uploaded file not found on disk" + logger.error(f"[MinerU] Task {task_id}: file not found at {upload_path_src}") + return + + task["status"] = "processing" + task["progress"] = 15 + task["updatedAt"] = datetime.utcnow().isoformat() + task["message"] = f"MinerU pipeline started (backend=pipeline, processType={process_type})" + logger.info(f"[MinerU] Task {task_id}: starting {process_type} on {file_name}") + + # Prepare output directory + output_dir = TASKS_DIR / task_id + output_dir.mkdir(parents=True, exist_ok=True) + + try: + pdf_bytes = read_fn(upload_path_src) + + if process_type == "markdown": + await aio_do_parse( + output_dir=str(output_dir), + pdf_file_names=[base_name], + pdf_bytes_list=[pdf_bytes], + p_lang_list=[lang], + backend="pipeline", + parse_method="auto", + f_dump_md=True, + f_dump_middle_json=False, + f_dump_model_output=False, + f_dump_orig_pdf=False, + f_dump_content_list=False, + f_draw_layout_bbox=False, + f_draw_span_bbox=False, + ) + # MinerU output: {output_dir}/{base_name}/auto/{base_name}.md + md_dir = output_dir / base_name / "auto" + md_path = md_dir / f"{base_name}.md" + images_dir = md_dir / "images" + + if md_path.exists(): + artifacts_list = [] + + # Register the .md artifact + md_art_id = str(uuid.uuid4()) + md_artifact = { + "artifactId": md_art_id, + "fileName": f"{base_name}.md", + "fileSize": md_path.stat().st_size, + "fileType": "md", + "filePath": str(md_path), + } + artifacts_list.append(md_artifact) + artifacts_map[md_art_id] = md_artifact + + # If images directory exists and has files, create a ZIP + if images_dir.exists() and any(images_dir.iterdir()): + zip_path = md_dir / f"{base_name}_result.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.write(md_path, md_path.name) + for img_file in images_dir.rglob("*"): + if img_file.is_file(): + arcname = f"images/{img_file.relative_to(images_dir)}" + zf.write(img_file, arcname) + zip_art_id = str(uuid.uuid4()) + zip_artifact = { + "artifactId": zip_art_id, + "fileName": f"{base_name}_result.zip", + "fileSize": zip_path.stat().st_size, + "fileType": "zip", + "filePath": str(zip_path), + } + artifacts_list.append(zip_artifact) + artifacts_map[zip_art_id] = zip_artifact + logger.info(f"[MinerU] Task {task_id}: ZIP created -> {zip_path} ({zip_path.stat().st_size} bytes)") + + artifacts_db[task_id] = artifacts_list + task["status"] = "completed" + task["progress"] = 100 + task["message"] = f"MinerU Markdown completed ({md_path.stat().st_size} bytes)" + logger.info(f"[MinerU] Task {task_id}: markdown completed -> {md_path}") + else: + task["status"] = "failed" + task["message"] = "MinerU did not produce .md output" + logger.error(f"[MinerU] Task {task_id}: no .md output at {md_path}") + + else: # ocrpdf — use OCRmyPDF for searchable dual-layer PDF + ocr_lang = {"ch": "chi_sim", "en": "eng", "japan": "jpn", "korean": "kor"}.get(lang, "chi_sim") + ocr_output = output_dir / f"{base_name}_ocr.pdf" + + await asyncio.to_thread( + ocrmypdf.ocr, + upload_path_src, + str(ocr_output), + language=ocr_lang, + output_type="pdf", + skip_text=True, + deskew=True, + optimize=0, # skip JBIG2 optimization (pikepdf compat) + ) + + if ocr_output.exists(): + art_id = str(uuid.uuid4()) + artifacts_db[task_id] = [{ + "artifactId": art_id, + "fileName": f"{base_name}_ocr.pdf", + "fileSize": ocr_output.stat().st_size, + "fileType": "pdf", + "filePath": str(ocr_output), + }] + artifacts_map[art_id] = artifacts_db[task_id][0] + task["status"] = "completed" + task["progress"] = 100 + task["message"] = f"OCRmyPDF completed ({ocr_output.stat().st_size} bytes)" + logger.info(f"[OCRmyPDF] Task {task_id}: ocrpdf completed -> {ocr_output}") + else: + task["status"] = "failed" + task["message"] = "OCRmyPDF did not produce output" + + except Exception as e: + task["status"] = "failed" + task["message"] = f"MinerU error: {e}" + task["progress"] = 0 + logger.error(f"[MinerU] Task {task_id}: exception - {e}") + + task["updatedAt"] = datetime.utcnow().isoformat() + + +# ── Entry point ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import uvicorn + port = 2026 + print(f"🚀 FairScan PC Server starting on http://0.0.0.0:{port}") + print(f" Stream: http://localhost:{port}") + print(f" Dashboard: http://localhost:{port}/dashboard") + print(f" Health: http://localhost:{port}/health") + print(f" Upload: POST http://localhost:{port}/upload/pdf") + print(f" Tasks: POST http://localhost:{port}/tasks/process") + uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") diff --git a/pc-server/requirements.txt b/pc-server/requirements.txt new file mode 100644 index 0000000..22cd8c5 --- /dev/null +++ b/pc-server/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +websockets>=12.0 +Pillow>=10.0.0 diff --git a/requirements/FIXES_SUMMARY.md b/requirements/FIXES_SUMMARY.md new file mode 100644 index 0000000..a57ade6 --- /dev/null +++ b/requirements/FIXES_SUMMARY.md @@ -0,0 +1,195 @@ +# UI 扩展修复总结 + +## 修改文件清单 + +### 1. SettingsRepository.kt +**变更**:添加了网络协作相关的配置项和枚举类型 + +**新增内容**: +- 7个新的 `stringPreferencesKey`: + - `SERVER_HOST` - PC主机地址 + - `SERVER_PORT` - PC端口 + - `SERVER_DISPLAY_NAME` - PC显示名称 + - `LAST_SELECTED_SERVICE_ID` - 上次选择的服务ID + - `STREAM_QUALITY` - 图传质量 + - `POST_PROCESS_MODE` - 后处理模式 + - `AUTO_DOWNLOAD_PROCESSED_RESULT` - 自动下载处理结果开关 + +- 7个新的 Flow 属性用于读取这些配置 + +- 8个新的 suspend fun setter 方法: + - `setServerHost()` + - `setServerPort()` + - `setServerDisplayName()` + - `setLastSelectedServiceId()` + - `setStreamQuality()` + - `setPostProcessMode()` + - `setAutoDownloadProcessedResult()` + +- 2个新的 enum 类型: + - `StreamQuality(LOW, BALANCED, HIGH)` - 图传质量档位 + - `PostProcessMode(MARKDOWN, OCRPDF)` - 后处理模式 + +**修复**:修复了第231行缺少类闭合括号的问题 + +--- + +### 2. SettingsViewModel.kt +**变更**:扩展了 UI 状态数据类和 combine flow + +**新增内容**: +- 扩展 `SettingsUiState` 数据类,添加了12个新字段: + - `serverHost: String?` + - `serverPort: Int` + - `serverDisplayName: String?` + - `lastSelectedServiceId: String?` + - `streamQuality: StreamQuality` + - `postProcessMode: PostProcessMode` + - `autoDownloadProcessedResult: Boolean` + +- 8个新的 ViewModel 方法与 Repository 的 setter 对接: + - `setServerHost()` + - `setServerPort()` + - `setServerDisplayName()` + - `setLastSelectedServiceId()` + - `setStreamQuality()` + - `setPostProcessMode()` + - `setAutoDownloadProcessedResult()` + +**修复**: +- 使用 `Array` 方式重写了 `combine()` 的 lambda,解决了12个参数类型推断失败的问题 +- 使用数组索引方式访问组合流的值,避免了 lambda 参数过多导致的编译错误 + +--- + +### 3. SettingsScreen.kt +**变更**:添加了网络协作 UI 界面 + +**新增内容**: +- 8个新的 lambda 参数到 `SettingsScreen()` 函数: + - `onServerHostChanged` + - `onServerPortChanged` + - `onStreamQualityChanged` + - `onPostProcessModeChanged` + - `onAutoDownloadChanged` + - `onScanNetworkHostsClick` + - `onTestConnectionClick` + +- 新增 "Network Collaboration" 部分 UI,包括: + - PC 服务器配置(主机地址和端口输入框) + - 当前连接状态显示 + - "扫描主机" 和 "测试连接" 按钮 + - 图传质量选择(低/均衡/高三档) + - 后处理模式选择(Markdown/OCR PDF) + - 自动下载处理结果开关 + +**修复**: +- 第309行:添加了缺失的 `SettingsContent` 函数结束的闭合括号 `}` +- 第230行:移除了 `keyboardType = KeyboardType.Number` 参数,改用基础的 `OutlinedTextField`,避免版本兼容性问题 + +--- + +### 4. strings.xml +**变更**:添加了新的本地化字符串资源 + +**新增内容**: +- `settings_section_network` - "Network Collaboration" 标题 +- `stream_quality` - "Stream Quality" 选项标题 +- `post_process_mode` - "Post Process Mode" 选项标题 + +这些资源用于 UI 显示,遵循现有的资源命名规范。 + +--- + +### 5. MainActivity.kt +**变更**:更新了 `SettingsScreen` 的调用 + +**新增内容**: +- 6个新的回调参数传递到 `SettingsScreen()`: + - `onServerHostChanged = { host -> settingsViewModel.setServerHost(host) }` + - `onServerPortChanged = { port -> settingsViewModel.setServerPort(port) }` + - `onStreamQualityChanged = { quality -> settingsViewModel.setStreamQuality(quality) }` + - `onPostProcessModeChanged = { mode -> settingsViewModel.setPostProcessMode(mode) }` + - `onAutoDownloadChanged = { enabled -> settingsViewModel.setAutoDownloadProcessedResult(enabled) }` + - `onScanNetworkHostsClick = { /* TODO */ }` + - `onTestConnectionClick = { /* TODO */ }` + +--- + +## 编译错误修复 + +### 原始错误 +1. **SettingsRepository.kt:231** - 缺少类闭合括号 +2. **SettingsScreen.kt:309** - 缺少函数结束括号 +3. **SettingsScreen.kt:230** - OutlinedTextField 的 keyboardType 参数不兼容 +4. **SettingsViewModel.kt:65** - combine 的 lambda 参数类型推断失败(12个参数过多) +5. **MainActivity.kt:283** - SettingsScreen 调用缺少新参数 + +### 修复方案 +1. 添加了缺失的闭合括号 +2. 使用 Array 方式重写 combine 的 lambda 参数,解决类型推断问题 +3. 移除了不兼容的 OutlinedTextField 参数 +4. 完整更新了所有调用点的参数传递 + +--- + +## 后续待办项目 + +这些是实现计划中的下一步任务: + +### P0:局域网发现与基础连接 +- Task P0-2:实现局域网发现基础能力(NSD) +- Task P0-3:补充网络基础设施(HTTP 客户端) + +### P1:实时图传 +- Task P1-2:实现帧压缩与抽帧策略 +- Task P1-3:相机页接入图传控制 + +### P2:手机本地 PDF 上传 +- Task P2-1:实现 PDF 上传客户端 + +### P3:统一处理任务与结果下载 +- Task P3-1:实现统一任务接口客户端 + +### P4:体验优化 +- Task P4-1:发现结果去重与缓存 + +--- + +## 验证步骤 + +1. **编译验证**: + ```bash + ./gradlew clean build + ``` + +2. **单元测试**(如果有): + ```bash + ./gradlew testDebugUnitTest + ``` + +3. **运行应用**: + - 打开应用 + - 进入设置页面 + - 验证新的"Network Collaboration"部分能正常显示 + - 验证所有输入框和按钮响应正常 + +--- + +## 技术细节 + +### 为什么使用 Array 方式处理 combine? +Kotlin 的 combine 函数最多支持约 9 个参数的类型推断,超过这个数量会导致编译器无法自动推断 lambda 参数类型。通过使用数组方式,我们规避了这个限制,同时保持代码的可读性。 + +### 为什么移除了 keyboardType? +某些 Jetpack Compose 版本中,`OutlinedTextField` 可能不支持 `keyboardType` 参数,或者参数名称/位置不同。通过使用基础的 `OutlinedTextField` API,我们确保代码与更多版本的 Compose 兼容。 + +--- + +## 文件修改统计 + +- 修改文件数:5 个 +- 新增代码行数:约 150 行 +- 修复编译错误:5 处 +- 新增功能点:20+ 个(包括新的参数、方法、UI 元素) + diff --git a/requirements/FairScan_reqirement.prg b/requirements/FairScan_reqirement.prg new file mode 100644 index 0000000000000000000000000000000000000000..dc5ee9549c8439827db29e7a0440d54d0d917dc3 GIT binary patch literal 2229 zcmWIWW@Zs#;Nak3c&l(Eh5-riGUzfCmn5dA>g5)v7bGTUhla2+FxGb}WN|SlfYmWF zNH9FG?4CL0^BqQX`s{S?oRCn7VySbBQY&f*_oN{l= zGS9~szUcU!J6l`-|Nh_l|JD1SwN9Sdc9MCQ>Q41lU&_7ID>df4kj~EITUkB3Gf~Je zvoYlm+mk@?9VToY%jN_(O<8^;B51qHavuJp!js&kQnXA}7e6e}5bSa*bBn%Z%o|+# z|K>c8nnTG}7w=EZ+_5G8v`W88+t(Betx6-2-jWn4HM=WDlD+9s@9w2f4qa+saXv8dHPf+4LOMB% zHbiS3+8f?%9Q*U8blrOogFCSjZ*+Xlh!u4^e*f-h^>)5g$L49BeSco|_kY)w{H|Z3R|Wcic2UxDUaG#5Uv(05?##3I^EP{J-h0Y+oqFY- zK=tYI>GuA1EAPLqzc$59ZR?&r^E%9qFO9owDY{lD8kY3z3WLL58;TI@+AGOc{8t-8tyf^i-2wpg8xzk!20K+|fZdW5O9`aPIPF zKfLo$O?_h74f&`O=VRAwdNW_%=xE@SzmKoWy*ZWlD~I)Fe3bmnyS>W0n>Q#lNhVCG zZkXWfpy1v3tV3gw$V9g_k!9z$1UgwAVEM|B=-w>Q!6@idHAPd$Cg$I}^4~9u51!oj z$0xh)*{1rnSLG!&&wT%_-lwn2r<+>!$#`vD*@gGEu`_l*xgW#BwoZi2`tO_82XA_f zv>fY~-=FyHc-c6Ugk0BcHy^DZxUJ=HFFsX+nJ8uXy}bS zDEMv1l$^73Zp^B@Q)IJ2M7g13Tf=Ul=1)^Zq9mV2O#N4&@FUCWMds)A^C}h@mppCC zKgQiW*DzJ?>{pu{*_fy|QOjOg+xi&vfHnKOC=6SyA*qH;} zv-|9BO#M53rmXzd<@|Y-e@=7V-BzB@y5?(vME>Im&IX@%IEiVjoV)0-#{#xMg(=?G zouz{FuKDyO-(F|9jv-o_rz2T!c>=Eujj_O>=^DKYHqV>xKUGcTp1;1S2stFsCNZankS=H=U@p^$Z3VZ{fA=qB%E z?!-BpChAQ2ed^w>zrHJL^Y{JrlH|6(`#3(kUo+U0rQ-72F9&t&*Y_RRxUkeYp=9>n z#!br#Ed}QZHgLE$bfhybzr|3VdqiWh+`^KxJObVcDzZFn2|OK=(mO(gVx&ELHG(~+ zOg_Uu)uu4(#ChKzFJHE=y?gai?eUlT)9dw|%*#{FWoti7dAI*={k5jYn;uAOqz0uf zDePfL6lC+r7H2V(v1zk?v~%6V8Eaw-ZVNdj^94=>sFz?`3Rv z*Lru0H{DAL(>TUtZ@c$O>CN+e&+^OcXTAP-`SQ=3A0OOtO4NB?{` 不支持 CIDR | 改用 `` | +| 帧未显示在浏览器 | 服务器未广播帧到浏览器客户端 | 添加 broadcast 循环 | +| 端口输入框"删除不干净" | `toIntOrNull()` 返回 null 后未更新 | 用 `remember` + `LaunchedEffect` | +| 下载的 PDF 为空白页 | `_create_minimal_pdf()` 缺少内容流 | 改为复制原始上传文件 | +| 上传进度卡在 0% | `upload_pdf` 未启动 `simulate_processing` | 添加 `asyncio.create_task`(后因分离重构移除) | +| Preview 函数编译错误 | 缺少 `onUploadAndProcess` 参数 | 添加 `onUploadAndProcess = {}` | + +--- + +## 架构总结 + +### 完整数据流 + +``` +相机预览 → liveAnalysis() + ├── → ML 分析(不变) → 文档页面 + │ + ├── → Streaming(图传开启时) + │ FrameCompressor → FrameDropController → OkHttpStreamClient → PC WS /stream → Browser + │ + └── → 拍照 → 处理 → PDF 生成 + ExportViewModel + ├── uploadPdfToServer() + │ → PdfUploadClient.uploadPdf() → PC POST /upload/pdf + │ → 返回 fileId → Uploaded(fileId, taskId=null) + │ + └── uploadAndProcess(processType) + → PdfUploadClient.uploadPdf() → PC POST /upload/pdf → fileId + → TaskClient.processPdf(fileId, processType) → PC POST /tasks/process → taskId + → Uploaded(fileId, taskId) +``` + +### PC 服务端架构 + +``` +files_db (dict): fileId → {fileId, fileName, sizeBytes, uploadPath, createdAt} +tasks_db (dict): taskId → {taskId, fileId, status, progress, processType, ...} +artifacts_db (dict): taskId → [{artifactId, fileName, ...}] +artifacts_map (dict): artifactId → {artifactId, fileName, filePath, ...} +``` + +### AppContainer 新增注入 +``` +- networkInfoProvider +- okHttpClient +- streamClient: StreamClient +- pdfUploadClient: PdfUploadClient +- taskClient: TaskClient +``` + +--- + +## PC 端端点总览 + +| 端点 | 方法 | 功能 | +|------|------|------| +| `/health` | GET | 健康检查 | +| `/` | GET | 图传预览页面 | +| `/stream` | WS | 接收 JPEG 帧 | +| `/dashboard` | GET | 管理面板页面 | +| `/api/dashboard` | GET | 管理面板 JSON 数据 | +| `/upload/pdf` | POST | 上传 PDF(纯上传,201) | +| `/tasks/process` | POST | 创建处理任务(202) | +| `/tasks/{taskId}` | GET | 查询任务状态 | +| `/tasks/{taskId}/artifacts` | GET | 查询任务产物列表 | +| `/artifacts/{artifactId}/download` | GET | 下载处理产物 | +| `/files/{fileId}/download` | GET | 下载已上传的原始文件 | + +--- + +## 文件清单 + +### 新增文件(Android 网络层) +1. `network/ServerEndpoint.kt` +2. `network/NetworkInfoProvider.kt` +3. `network/stream/StreamState.kt` +4. `network/stream/StreamQualityPreset.kt` +5. `network/stream/FrameCompressor.kt` +6. `network/stream/FrameDropController.kt` +7. `network/stream/OkHttpStreamClient.kt` +8. `network/upload/PdfUploadClient.kt` +9. `network/tasks/TaskModels.kt` +10. `network/tasks/TaskClient.kt` +11. `res/xml/network_security_config.xml` +12. `network/discovery/DiscoveredHost.kt`(占位,待 P0 实现) +13. `network/discovery/DiscoveryState.kt`(占位,待 P0 实现) +14. `network/discovery/LanServiceDiscovery.kt`(占位,待 P0 实现) + +### 新增文件(PC) +15. `pc-server/main.py` + +### 修改文件 + +| 文件 | 修改内容 | +|------|---------| +| `gradle/libs.versions.toml` | 添加 OkHttp 4.12.0 | +| `app/build.gradle.kts` | 添加 OkHttp 依赖 | +| `AndroidManifest.xml` | 添加网络权限、网络安全配置 | +| `FairScanApp.kt` | 添加 okHttpClient、streamClient、pdfUploadClient、taskClient | +| `CameraViewModel.kt` | 添加图传字段和方法、帧率控制 | +| `CameraScreen.kt` | 添加 StreamToggleButton | +| `SettingsRepository.kt` | 添加 StreamFrameRate、ServerHost、ServerPort 等 | +| `SettingsViewModel.kt` | 添加 streamFrameRate、serverHost 等字段 | +| `SettingsScreen.kt` | 添加帧率选择、网络配置 UI | +| `MainActivity.kt` | 添加上传回调、taskPanelState 收集、downloadResult 回调 | +| `ExportViewModel.kt` | 添加 uploadPdfToServer()、uploadAndProcess()、downloadResult()、startPolling() | +| `ExportUiState.kt` | 添加 UploadState、RemoteTask、TaskPanelState、DownloadState | +| `ExportScreen.kt` | 添加上传按钮、TaskPanelSection、TaskRow UI 组件 | +| `pc-server/main.py` | 添加 MinerU 真实接入、ZIP 打包、HF_HUB_OFFLINE | + +--- + +## 待实现 + +| 项目 | 状态 | +|------|------| +| **OCRmyPDF 真实接入** | **📌 下一步**(当前 ocrpdf 用 MinerU 生成 layout PDF,非真正双层可搜索 PDF) | +| NSD 局域网自动发现 | 📌 占位(接口已定义) | +| 设置页"扫描主机"/"测试连接"按钮功能 | 📌 待实现 | +| 图传延迟/帧率实时显示 | 🔜 可优化 | + +--- + +**修改人**:Claude Code +**最后更新**:2026-06-04 +**修改类型**:Feature - Streaming + Upload/Process Pipeline + Dashboard + Real MinerU + Task Panel + ZIP diff --git a/requirements/NEXT_STEPS.md b/requirements/NEXT_STEPS.md new file mode 100644 index 0000000..fd221ca --- /dev/null +++ b/requirements/NEXT_STEPS.md @@ -0,0 +1,145 @@ +# 下一步实现计划 + +## 现状总结 + +✅ **P1 实时图传**:已完成 +✅ **P2/P3 上传与任务处理**:已完成 +✅ **MinerU 真实接入**:已完成(markdown 处理 + ZIP 打包) +✅ **任务管理面板**:已完成(手机端轮询 + 下载到指定目录) + +### MinerU markdown 已实现 +- 使用 `aio_do_parse()` 异步接口,pipeline 后端 +- `HF_HUB_OFFLINE=1` 使用本地缓存模型 +- 输出 `.md` + `images/` + `{name}_result.zip` 三种 artifact + +### 任务管理面板已实现 +- 手机端 `TaskPanelSection`:排队中 / 处理中 / 已完成 / 失败 四种状态 +- 2 秒轮询 PC 任务状态,自动更新 UI +- SAF 目录选择 → 下载到指定目录 → 打开文件 + +### 当前 ocrpdf 的局限性 + +⚠️ 当前 `processType=ocrpdf` 使用 MinerU 的 `f_draw_layout_bbox=True` 生成 layout PDF(在 PDF 上画布局框),**不是真正的 OCR 双层 PDF**。 + +真正的 OCRmyPDF 应该: +- 保留原始 PDF 的视觉外观 +- 在图像层上叠加透明文字层(text layer) +- 结果可通过 Ctrl+F 搜索文字 +- 文件可被屏幕阅读器朗读 + +--- + +## 下一步:OCRmyPDF 真实接入 🔥 + +### 目标 +用 `ocrmypdf` 库替换当前 MinerU 的 layout PDF 生成,产出真正的可搜索双层 PDF。 + +### 为什么需要 OCRmyPDF 而不是继续用 MinerU 做 ocrpdf + +| 特性 | MinerU layout PDF | OCRmyPDF | +|------|-------------------|----------| +| 可搜索文字 | ❌ 仅图片上的框 | ✅ 透明文字层 | +| 保留原始外观 | ❌ 重新渲染 | ✅ 原样保留 | +| 文件大小 | 较小 | 完整保留原 PDF | +| 用途 | 可视化版面分析 | 归档、检索、无障碍 | + +### 实现方案 + +`ocrmypdf` 是一个 Python 命令行工具/库,在 MinerU 的 conda 环境中安装: + +```bash +conda activate MinerU +pip install ocrmypdf +``` + +**统一环境说明**:MinerU 和 OCRmyPDF 共用一个 conda 环境 `MinerU`,PC 服务器始终在该环境下运行: + +```bash +conda activate MinerU +cd pc-server +python main.py +``` + +### 环境信息 + +| 项目 | 值 | +|------|-----| +| Conda 环境名 | `MinerU` | +| 环境路径 | `D:/ProgramData/miniconda3/envs/MinerU/` | +| Python | 3.10.20 | +| PyTorch | 2.6.0+cu124 | +| CUDA | 12.4 | +| GPU | RTX 4060 Laptop (8 GB VRAM) | +| MinerU | 3.0.9(已接入 markdown) | +| OCRmyPDF | 15.4.4(✅ 已安装,源码 `F:/datasets_rm/ocRmypdf`,v15.4.4 标签) | +| Tesseract | ❌ 待安装(OCRmyPDF 必需依赖) | +| 用途 | MinerU markdown 处理 + OCRmyPDF 双层 PDF 处理 | + +### 安装 Tesseract + +OCRmyPDF 依赖 Tesseract 做实际 OCR 文字识别。Windows 安装: + +```bash +# 方式1:conda(推荐,与 MinerU 同一环境) +conda activate MinerU +conda install -c conda-forge tesseract + +# 方式2:手动安装 +# 下载安装包:https://github.com/UB-Mannheim/tesseract/wiki +# 安装后确认: +tesseract --list-langs # 应包含 chi_sim, eng +``` + +安装中文语言包: +```bash +# conda 方式 +conda install -c conda-forge tesseract-lang + +# 或手动下载 chi_sim.traineddata 放到 tessdata 目录 +``` + +然后在 `pc-server/main.py` 的 `process_with_mineru` 中,`ocrpdf` 分支改为调用 OCRmyPDF: + +```python +import ocrmypdf + +# ocrpdf 分支 +ocrmypdf.ocr( + upload_path_src, # 输入 PDF + str(output_dir / f"{base_name}_ocr.pdf"), # 输出 PDF + language="chi_sim", # 中文简体 + output_type="pdf", + skip_text=True, # 跳过已有文字层 + deskew=True, # 纠偏 + clean=True, # 清理 +) +``` + +输出:真正的可搜索双层 PDF。 + +### 语言映射 + +| MinerU lang | OCRmyPDF language | +|-------------|-------------------| +| `ch` | `chi_sim` | +| `en` | `eng` | +| `japan` | `jpn` | +| `korean` | `kor` | + +### 待确认 + +- [x] `ocrmypdf` 已安装到 MinerU conda 环境(v15.4.4) +- [ ] Tesseract OCR 引擎已安装 +- [ ] Tesseract 语言包(`chi_sim`, `eng`)已安装 + +--- + +## P0:局域网发现与连接校验(待排期) + +### 目标 +让手机能够自动发现同一局域网中的 FairScan PC 服务。 + +### 已有占位文件 +- `network/discovery/LanServiceDiscovery.kt`(接口定义) +- `network/discovery/DiscoveryState.kt`(状态模型) +- `network/discovery/DiscoveredHost.kt`(主机模型) diff --git a/requirements/implementation-plan.md b/requirements/implementation-plan.md new file mode 100644 index 0000000..0935c26 --- /dev/null +++ b/requirements/implementation-plan.md @@ -0,0 +1,1385 @@ +# FairScan 功能细化规划(执行版) + +> 基于 `requirements/requirements.md` 的进一步落地拆解。 +> 本文件用于实现阶段的任务规划、模块拆分、接口约定、风险控制与验收标准。 +> +> 📌 **实现状态**: +> - P1(实时图传)✅ 已完成 +> - P2(PDF 上传)+ P3(统一处理任务)✅ 已完成 +> - 上传与处理已分离为独立接口,符合 pc-api-spec.md 规范 +> - 导出页提供三个按钮:仅传输 / 传输+OCR PDF / 传输+Markdown +> - PC 管理面板 ✅ 已完成(/dashboard) +> - MinerU 真实接入 ✅ 已完成(markdown 处理 + ZIP 打包 + 任务管理面板) +> - OCRmyPDF 🔥 **下一步** +> - P0(局域网发现)📌 待排期 +> +> 重要说明: +> +> - 本文档**不是只写给当前对话中的执行者**。 +> - 本文档应被视为一份**可交给任意工程执行者或 AI 编码代理**的实施说明。 +> - 例如 Claude Code、其他 AI 编码工具、或人工开发者,都应能依据本文件理解目标、边界、优先级与接口契约。 +> - 因此本文档尽量避免“只对当前上下文成立”的描述,改为更稳定的模块边界、任务拆分与接口说明。 + +## 0. 核心结论 + +本项目后续新增能力,分成两条彼此解耦的主链路: + +1. **实时图传链路** + - 目标:低延迟、稳定预览 + - 输入:手机相机预览帧 + - 输出:PC 实时显示画面 + - 特点:允许丢帧,不追求完整性 + +2. **文档处理链路** + - 目标:正式文档处理与结果回到手机 + - 输入:手机端本地生成的 PDF + - 输出:Markdown 或 OCR 后的 PDF + - 特点:追求正确性与完整性,可异步处理 + +同时明确以下原则: + +- **不在 App 内主动切换 Wi‑Fi** +- **局域网相机功能的本质是实时图传,不是文档处理** +- **文档处理只基于手机本地正式生成的 PDF,不基于图传流** +- **PC 端后处理只需预留统一任务接口,MinerU 与 OCRmyPDF 复用同一套处理协议** +- **处理结果回到手机,推荐通过“手机轮询状态 + 手机主动下载产物”实现** + +--- + +## 1. 当前项目基础能力梳理 + +结合现有代码,当前 Android 端已经具备以下基础: + +- 文档扫描采集与页面处理 + - 相机实时预览、分割、文档边缘检测:`app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt:98` + - 拍照后生成处理页:`app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt:138` + - 裁切/页面编辑/顺序调整入口:`app/src/main/java/org/fairscan/app/MainActivity.kt:190` +- PDF/JPEG 导出 + - 导出准备与结果管理:`app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:66` + - PDF 生成入口:`app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:76` + - PDF 写入封装:`app/src/main/java/org/fairscan/app/data/FileManager.kt:46` +- 设置页基础结构 + - 设置存储:`app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt:31` + - 设置界面:`app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt:57` +- 应用容器可扩展网络模块注入 + - 容器入口:`app/src/main/java/org/fairscan/app/FairScanApp.kt:46` + +这意味着后续新增能力不需要重做扫描器,而是围绕以下两条主链路扩展: + +1. **实时图传链路**:手机相机预览 -> PC 实时显示 +2. **文档处理链路**:手机扫描成 PDF -> PC 处理 -> 结果回到手机 + +--- + +阅读的文档顺序 + + 1. IMPLEMENTATION_COMPLETE.md - 了解已完成的工作 + 2. FIXES_SUMMARY.md - 理解技术决策和修复方案 + 3. NEXT_STEPS.md - 明确下一步的实现方向 + 你每次完成一个任务就需要更新这些文档 + +## 2. 需求重述与产品化定义 + +### 2.1 原有离线扫描功能 + +这部分以现有实现为基础,后续原则是: + +- 保持当前扫描主流程稳定 +- 不让实时图传影响正式扫描质量 +- 后续所有文档处理都复用现有扫描结果 + +建议统一定义“文档结果对象”: + +- 页面集合(已裁切、已旋转、已滤镜处理) +- 本地导出的 PDF 文件 +- 可选 JPEG 文件集合 +- 文档元信息(文件名、页数、时间) + +### 2.2 局域网相机功能 + +这里明确产品定位: + +- 该功能本质上是**局域网实时图传** +- 主要目标是:**低延迟、稳定、可预览** +- PC 端主要用途是: + - 实时观察手机画面 + - 辅助取景 + - 远端监看 +- **不把图传流直接作为 MinerU/OCRmyPDF 的输入** +- **不从图传流直接生成正式 PDF** + +也就是说: + +- 图传负责“看” +- 扫描链路负责“生成正式文档” + +### 2.3 离线扫描 PDF 发送到 PC 主机 + +主链路定义如下: + +- 手机端完成扫描与页面处理 +- 手机端本地生成 PDF +- 手机端把 PDF 发送到 PC 主机 +- PC 主机接收 PDF 后再执行后处理任务 + +这条链路的好处是: + +- 延续现有扫描架构 +- 手机端产物清晰、统一 +- PC 端只负责重型处理任务 +- 图传模块与文档处理模块不会互相拖累 + +### 2.4 统一 PC 后处理能力 + +这里明确一个关键约束: + +- **PC 端后处理只需要预留统一接口,不需要为 MinerU 与 OCRmyPDF 设计两套完全不同的协议。** + +建议抽象为一套统一任务模型: + +- 输入:手机上传的 PDF +- 处理类型:`markdown` 或 `ocrpdf` +- 输出: + - `markdown` -> 返回 `.md` 及相关资源 + - `ocrpdf` -> 返回处理后的 `.pdf` + +也就是说: + +- **接口统一** +- **任务状态统一** +- **产物查询统一** +- **差异只体现在处理类型和产物 MIME 类型** + +### 2.5 结果回到手机 + +从产品描述上可以说: + +- PC 处理完毕后,把结果回传到手机 + +但从工程实现角度,推荐这样定义: + +- 手机端上传后拿到 `taskId` +- 手机端轮询任务状态 +- 任务完成后,手机端拉取产物列表 +- 手机端主动下载产物到本地 + +这样做比“PC 主动推送到手机”更适合 Android: + +- 不需要手机长期监听端口 +- 更适合前台/后台切换 +- 更容易失败重试 +- 也更适合未来交由不同执行者实现 + +--- + +## 3. 建议的总体架构 + +建议按“手机端 + PC 端服务”的双端架构设计。 + +### 3.1 两条主链路 + +#### A. 实时图传链路 + +```text +手机相机预览帧 +-> 抽帧/压缩 +-> WebSocket 发送 +-> PC 实时预览 +``` + +特点: + +- 目标是低延迟 +- 允许丢帧 +- 不追求逐帧可靠到达 +- 不参与正式 PDF 生成 + +#### B. 文档处理链路 + +```text +手机扫描采集 +-> 手机本地页面处理 +-> 手机本地生成 PDF +-> HTTP 上传到 PC +-> 创建统一处理任务 +-> PC 执行对应处理器 +-> 手机查询任务状态 +-> 手机下载处理结果 +``` + +特点: + +- 目标是正确性与完整性 +- 使用正式文档产物作为输入 +- 允许异步处理 +- 与图传链路解耦 + +### 3.2 手机端职责(当前仓库) + +- 继续负责扫描采集、裁切、页面编辑、导出 PDF +- 提供局域网发现与主机配置能力 +- 提供实时图传能力 +- 上传本地 PDF 到 PC +- 发起统一处理任务 +- 轮询任务状态并下载结果 +- 在 UI 中展示连接状态、任务状态、结果入口 + +### 3.3 PC 端职责(建议新建单独项目) + +- 广播局域网服务信息 +- 接收图传帧并实时显示 +- 接收手机上传的 PDF 文件 +- 暴露统一处理任务接口 +- 根据处理类型调用 MinerU 或 OCRmyPDF +- 提供任务查询与产物下载接口 + +### 3.4 推荐协议选型 + +为了降低复杂度,建议如下: + +- 局域网发现:**mDNS / NSD** +- 实时图传:**WebSocket 二进制帧** +- 文件上传:**HTTP multipart/form-data** +- 任务创建:**HTTP POST** +- 任务状态查询:**HTTP 轮询** +- 结果下载:**HTTP GET** + +第一版不建议直接做: + +- App 内切换 Wi‑Fi +- 自定义复杂 UDP 视频协议 +- PC 主动推送文件到手机 +- 多主机自动同步 + +### 3.5 关于实时性的技术取舍 + +由于实时性优先,建议这样分层: + +#### V1 方案 + +- 低频抽帧 +- JPEG 压缩 +- WebSocket 发送 +- 明确丢帧策略 + +优点: + +- 实现快 +- 调试简单 +- 适合先验证局域网低延迟图传是否满足需求 + +#### V2 升级预案(仅在 V1 不满足时再评估) + +- 使用 MediaCodec 编码 H.264 +- 再评估 WebRTC / RTP / 更底层的视频链路 + +不建议首版直接上 V2,因为会显著提高开发复杂度。 + +--- + +## 4. 模块拆分规划 + +## 4.1 模块 A:局域网连接与主机发现 + +### 目标 + +让手机端可以: + +- 自动发现同一局域网中的 FairScan PC 服务 +- 手动填写主机地址作为兜底 +- 保存当前选中的 PC 主机配置 + +### 手机端新增能力 + +- 新增网络设置项 + - 主机 IP / 域名 + - 端口 + - 协议(第一期建议仅 http) +- 显示当前手机 IP +- 显示当前发现到的主机列表 +- 支持一键选择已发现主机 +- 测试连接按钮 +- 可选:打开系统 Wi‑Fi 设置入口 + +### 局域网发现机制 + +推荐采用: + +- **mDNS/NSD 自动发现 + 手动输入兜底** + +#### 推荐服务标识 + +- mDNS service type:`_fairscan._tcp` +- service instance name:`FairScan-PC-{deviceName}` + +#### 推荐 TXT Record 字段 + +- `name`:设备显示名 +- `features`:`upload,stream,process,download` +- `version`:PC 服务版本 +- `apiVersion`:接口版本 + +说明: + +- 这里建议把后处理能力统一成 `process`,而不是在发现层暴露过多具体工具细节。 +- 是否支持 `markdown` / `ocrpdf`,可以通过健康检查响应或任务能力字段进一步细分。 + +#### Android 端发现状态模型 + +```text +DiscoveryState +- Idle +- Discovering +- Success(list) +- Empty +- Error(message) +``` + +#### 发现结果建议结构 + +```text +DiscoveredHost +- serviceName +- displayName +- host +- port +- features[] +- version +- lastSeenAt +- isReachable +``` + +### 建议新增数据项 + +在 `SettingsRepository` 中新增: + +- `serverHost` +- `serverPort` +- `serverDisplayName` +- `lastSelectedServiceId` +- `streamQuality` +- `postProcessMode` +- `autoDownloadProcessedResult` + +### 建议涉及文件 + +- `app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt` +- `app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt` +- `app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt` +- 建议新增: + - `app/src/main/java/org/fairscan/app/network/NetworkInfoProvider.kt` + - `app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt` + - `app/src/main/java/org/fairscan/app/network/discovery/LanServiceDiscovery.kt` + - `app/src/main/java/org/fairscan/app/network/discovery/NsdLanServiceDiscovery.kt` + - `app/src/main/java/org/fairscan/app/network/discovery/DiscoveryState.kt` + - `app/src/main/java/org/fairscan/app/network/discovery/DiscoveredHost.kt` + +### 验收标准 + +- 用户可以扫描到局域网中的 FairScan PC 服务 +- 用户可以从发现列表中选择目标主机 +- 自动发现失败时仍可手动输入地址 +- 选择主机后可通过 `GET /health` 校验连通性 + +--- + +## 4.2 模块 B:实时网络图传 + +### 目标 + +把手机作为一个**低延迟局域网实时图传设备**,让 PC 端可以实时看到画面。 + +### 功能定位 + +这一模块的核心不是文档处理,而是: + +- 实时预览 +- 低延迟 +- 稳定连接 +- 图传状态清晰 + +### 推荐实现路线 + +第一版建议: + +- 从 CameraX 预览/分析流中取图 +- 按固定频率抽帧 +- 压缩为 JPEG +- 通过 WebSocket 发送二进制帧到 PC +- PC 端显示实时画面 + +当前可接入入口: + +- `app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt:98` + +### 关键原则 + +- **必须允许丢帧**,不能把帧积压在队列里 +- **必须以实时性优先**,而不是完整性优先 +- **图传失败不能影响正式扫描** +- **图传流不参与正式 PDF 生成** + +### 压缩档位建议 + +| 档位 | 最长边 | JPEG质量 | 目标FPS | 用途 | +|---|---:|---:|---:|---| +| Low | 640 | 45 | 8~12 | 低延迟预览 | +| Balanced | 960 | 60 | 6~10 | 默认 | +| High | 1280 | 75 | 5~8 | 高清预览 | + +### 手机端子任务 + +1. 设计图传状态模型 +2. 新增帧压缩器 +3. 新增图传客户端 +4. 增加丢帧策略 +5. 在相机界面增加图传开关和状态提示 +6. 增加断线重连策略 +7. 增加简单性能指标 + +### 建议涉及文件 + +已有接入点: + +- `app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt:98` +- `app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt` +- `app/src/main/java/org/fairscan/app/ui/screens/camera/CameraPreview.kt` + +建议新增: + +- `app/src/main/java/org/fairscan/app/network/stream/StreamClient.kt` +- `app/src/main/java/org/fairscan/app/network/stream/FrameCompressor.kt` +- `app/src/main/java/org/fairscan/app/network/stream/StreamState.kt` +- `app/src/main/java/org/fairscan/app/network/stream/StreamQualityPreset.kt` +- `app/src/main/java/org/fairscan/app/network/stream/FrameDropController.kt` + +### 验收标准 + +- 手机连接 PC 后可持续发送实时预览帧 +- 在局域网环境下主观延迟明显低于文件传输式刷新 +- 不同压缩档位的延迟和清晰度有可见差异 +- 图传开启时,正式扫描/导出功能仍可正常使用 + +--- + +## 4.3 模块 C:手机本地生成 PDF 并上传到 PC + +### 目标 + +保持现有手机扫描方案不变,在手机端完成正式文档生成后,再把 PDF 上传给 PC。 + +### 推荐实现路线 + +- 手机端按现有流程完成扫描 +- 使用现有导出逻辑生成 PDF +- 调用上传接口把 PDF 发给 PC + +当前稳定入口: + +- `app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:76` +- `app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:84` +- `app/src/main/java/org/fairscan/app/data/FileManager.kt:46` + +### 设计原则 + +- 正式文档输入只认手机本地生成的 PDF +- 不使用实时图传流直接做正式文档处理 +- 上传是文档处理链路的前半段,不与实时图传混合 + +### 手机端子任务 + +1. 定义上传请求/响应模型 +2. 导出页增加“发送到 PC”按钮 +3. 支持发送后立即创建后处理任务,或只上传不处理 +4. 展示上传进度与结果 +5. 记录返回的文件标识或任务标识 + +### 建议涉及文件 + +- `app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:66` +- `app/src/main/java/org/fairscan/app/MainActivity.kt:203` +- 建议新增: + - `app/src/main/java/org/fairscan/app/network/upload/PdfUploadClient.kt` + - `app/src/main/java/org/fairscan/app/network/upload/UploadModels.kt` + - `app/src/main/java/org/fairscan/app/ui/screens/export/NetworkExportState.kt` + +### 验收标准 + +- 用户可以把当前扫描结果导出成 PDF 并发送给 PC +- 上传进度可见 +- 成功后能拿到后续处理所需的文件标识或任务标识 + +--- + +## 4.4 模块 D:统一 PC 后处理接口 + +### 目标 + +为 PC 端预留**一套统一的后处理任务接口**,由处理类型决定具体使用 MinerU 还是 OCRmyPDF。 + +### 设计原则 + +- 统一任务创建接口 +- 统一任务状态接口 +- 统一产物列表接口 +- 统一产物下载接口 +- 差异只体现在: + - `processType` + - 输出文件类型 + +### 推荐任务类型 + +```text +processType +- markdown +- ocrpdf +``` + +### 输入输出约定 + +#### 输入 + +- 一个已上传或本次提交的 PDF +- 一个 `processType` + +#### 输出 + +- 当 `processType=markdown` + - 主要产物:`.md` + - 可选附加产物:图片资源目录、JSON、日志 +- 当 `processType=ocrpdf` + - 主要产物:处理后的 `.pdf` + - 可选附加产物:日志、识别报告 + +### 推荐接口契约 + +#### 1. 创建任务 + +- `POST /tasks/process` + +请求体建议: + +```json +{ + "fileId": "uploaded-file-id", + "processType": "markdown", + "options": {} +} +``` + +#### 2. 查询任务状态 + +- `GET /tasks/{taskId}` + +返回体建议: + +```json +{ + "taskId": "task-123", + "status": "running", + "processType": "markdown", + "progress": 50, + "message": "processing", + "artifacts": [] +} +``` + +#### 3. 查询产物列表 + +- `GET /tasks/{taskId}/artifacts` + +返回体建议: + +```json +[ + { + "artifactId": "artifact-1", + "fileName": "result.md", + "mimeType": "text/markdown", + "role": "primary" + } +] +``` + +#### 4. 下载产物 + +- `GET /artifacts/{artifactId}/download` + +### 手机端实现含义 + +手机端不需要关心 PC 内部到底调用了 MinerU 还是 OCRmyPDF,只需要关心: + +- 要处理哪个文件 +- 处理类型是什么 +- 当前任务状态是什么 +- 可以下载哪些产物 + +### 这样设计的好处 + +- PC 端工具实现可替换 +- 后续新增其他处理器时接口不必大改 +- 文档可交给不同 AI 或开发者实现而不易跑偏 +- Android 端状态管理更统一 + +### 建议新增文件 + +- `app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt` +- `app/src/main/java/org/fairscan/app/network/tasks/TaskModels.kt` +- `app/src/main/java/org/fairscan/app/network/tasks/ArtifactDownloadClient.kt` +- `app/src/main/java/org/fairscan/app/ui/screens/export/ProcessingTaskState.kt` + +### 验收标准 + +- 手机端可通过统一接口发起 `markdown` 任务 +- 手机端可通过统一接口发起 `ocrpdf` 任务 +- 任务状态查询逻辑对两类任务保持一致 +- 产物下载逻辑对两类任务保持一致 + +--- + +## 4.5 模块 E:处理结果回到手机端 + +### 目标 + +让 PC 端的处理结果真正回到手机端,而不是停留在 PC 上。 + +### 推荐实现方式 + +产品上: + +- 结果回传手机 + +技术上: + +- 手机端主动下载 + +### 为什么不建议 PC 主动推送到手机 + +- Android 端长期开放监听端口不稳定 +- 前后台切换复杂 +- 手机网络权限与系统限制更多 +- 局域网内由手机主动拉取更简单可靠 + +### 手机端下载结果后的处理建议 + +- 下载到应用缓存目录 +- 再提供: + - 打开 + - 分享 + - 保存到用户指定目录 + - 作为新文档导入应用 + +### 验收标准 + +- 任务完成后手机端可获取产物列表 +- 手机端可把产物下载回本地 +- 下载失败时可重试 +- 用户可明确区分“任务失败”和“下载失败” + +--- + +## 5. 推荐开发阶段划分 + +## 第一阶段:局域网发现与连接校验 + +目标:先把“发现谁、连接谁”做稳定。 + +### 范围 + +- 设置页新增 PC 主机配置 +- mDNS/NSD 发现机制 +- 手动输入兜底 +- `GET /health` 健康检查 + +### 阶段验收 + +- 手机端可自动发现同局域网中的 FairScan PC 服务 +- 发现失败时可手动输入地址 +- 健康检查可明确提示成功/失败 + +--- + +## 第二阶段:实时图传闭环 + +目标:优先完成低延迟实时图传。 + +### 范围 + +- 图传客户端 +- 相机页图传开关 +- 图传压缩档位 +- PC 端实时预览 +- 复用第一阶段的主机发现结果 + +### 阶段验收 + +- 手机端可以把实时预览帧发送到 PC +- PC 端能低延迟显示画面 +- 图传质量档位切换有效 +- 图传失败不影响正常扫描 + +--- + +## 第三阶段:手机本地 PDF 上传到 PC + +目标:打通文档处理链路的前半段。 + +### 范围 + +- 手机本地导出 PDF +- 上传到 PC +- 上传状态展示 + +### 阶段验收 + +- 当前扫描文档可生成 PDF 并上传至 PC +- 上传结果可见 +- 可关联后续处理任务 + +--- + +## 第四阶段:统一处理任务与结果下载 + +目标:打通文档处理链路的后半段。 + +### 范围 + +- 统一处理任务接口 +- `markdown` 处理类型 +- `ocrpdf` 处理类型 +- 手机查询任务状态 +- 手机下载结果文件 + +### 阶段验收 + +- 手机可用统一接口发起两种处理类型 +- PC 可根据类型调用不同处理器 +- 结果文件可从 PC 下载回手机 + +--- + +## 第五阶段:体验优化 + +### 范围 + +- 最近连接记录 +- 自动下载开关 +- 失败重试 +- 后台通知 +- 发现结果去重与缓存 +- 可选:UDP/网段探测作为发现兜底 + +--- + +## 5.1 局域网发现专项方案 + +### 目标 + +在不切换 Wi‑Fi 的前提下,让手机端自动发现同一局域网中运行中的 FairScan PC 服务。 + +### 主方案 + +- PC 端广播 `_fairscan._tcp` +- Android 端 NSD 搜索 `_fairscan._tcp` +- 用户确认选择目标主机 +- 手机端调用 `GET /health` 校验 + +### 发现流程 + +1. PC 启动 HTTP 服务 +2. PC 注册 mDNS 服务 +3. 手机点击“扫描局域网主机” +4. Android NSD 开始发现 +5. 解析 host、port、features +6. 展示设备列表 +7. 用户选择设备 +8. 手机执行 `GET /health` +9. 校验成功后保存为当前目标主机 + +### 备用方案触发条件 + +只有在以下情况长期存在时,才考虑第二通道: + +- 大量设备无法发现 mDNS +- 企业网络场景较多 +- Windows 防火墙导致广播不稳定 + +此时再评估: + +- 自定义 UDP 广播 +- 指定网段 HTTP 探测 + +默认不建议首版同时做两套发现机制。 + +--- + +## 6. Android 端建议修改点清单 + +## 6.1 权限与清单 + +当前 Manifest 尚未为新网络能力补齐联网权限: + +- `app/src/main/AndroidManifest.xml:1` + +后续大概率需要新增: + +- `android.permission.INTERNET` +- `android.permission.ACCESS_WIFI_STATE` +- 第一版不建议使用 `android.permission.CHANGE_WIFI_STATE` + +如果使用明文 HTTP 局域网服务,还要评估: + +- network security config +- 或仅在开发阶段允许局域网明文流量 + +## 6.2 设置页 + +当前设置页很适合作为局域网协作入口: + +- `app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt:57` +- `app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt:31` + +建议新增“局域网协作”分组: + +- PC 主机地址 +- 端口 +- 当前手机 IP +- 扫描局域网主机按钮 +- 已发现主机列表 +- 图传质量预设 +- 默认处理类型 +- 自动下载结果开关 +- 测试连接按钮 +- 打开系统 Wi‑Fi 设置快捷入口(可选) + +## 6.3 相机页 + +当前相机实时分析入口适合接入图传: + +- `app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt:98` + +建议新增: + +- 图传开关 +- 图传状态文本 +- 当前目标主机提示 +- 压缩档位快速切换 +- 近似发送帧率或发送状态提示 + +## 6.4 导出页 + +当前导出能力已较完整: + +- `app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt:66` + +建议新增按钮: + +- 发送到 PC +- 发送并处理(Markdown) +- 发送并处理(OCR PDF) +- 下载处理结果(当任务完成后显示) + +## 6.5 任务与结果页面 + +建议后续增加一个轻量任务状态区域,至少包含: + +- 上传中 +- 等待处理 +- 处理中 +- 处理成功 +- 处理失败 +- 下载中 +- 下载成功 +- 下载失败 + +## 6.6 AppContainer 注入 + +当前容器适合新增网络模块注入: + +- `app/src/main/java/org/fairscan/app/FairScanApp.kt:46` + +建议后续加入: + +- `lanServiceDiscovery` +- `streamClient` +- `uploadClient` +- `taskClient` +- `artifactDownloadClient` +- `networkInfoProvider` + +--- + +## 7. PC 端服务建议最小方案 + +### 7.1 目标边界 + +PC 端在本阶段只需要提供: + +- 图传接收能力 +- 文件接收能力 +- **统一处理任务接口骨架** +- 任务状态返回能力 +- 产物下载能力 + +这里特别强调: + +- 对当前阶段来说,PC 端**只需预留好统一接口** +- MinerU 与 OCRmyPDF 的内部执行器可以后续再逐步接入 +- 但接口契约应先稳定下来,避免 Android 端后面反复改协议 + +### 7.2 推荐技术路线 + +如果 MinerU 与 OCRmyPDF 最终都运行在 Python 环境,最自然的是: + +- PC 端统一使用 **Python FastAPI** + +### 7.3 PC 端最小能力 + +- HTTP 服务 +- WebSocket 图传接收 +- mDNS 服务广播 +- 上传文件保存 +- 统一任务接口 +- 任务状态查询 +- 结果文件下载 + +### 7.4 最小接口建议 + +- `GET /health` + - 健康检查 +- `WS /stream` + - 实时图传接收 +- `POST /upload/pdf` + - 上传 PDF +- `POST /tasks/process` + - 发起统一处理任务 +- `GET /tasks/{id}` + - 查询任务状态 +- `GET /tasks/{id}/artifacts` + - 查询产物列表 +- `GET /artifacts/{artifactId}/download` + - 下载产物 + +### 7.5 统一处理任务接口示例 + +#### 创建任务请求示例 + +```json +{ + "fileId": "uploaded-file-id", + "processType": "ocrpdf", + "options": {} +} +``` + +#### 状态返回示例 + +```json +{ + "taskId": "task-123", + "status": "completed", + "processType": "ocrpdf", + "progress": 100, + "message": "done" +} +``` + +#### 产物列表示例 + +```json +[ + { + "artifactId": "artifact-1", + "fileName": "result.pdf", + "mimeType": "application/pdf", + "role": "primary" + } +] +``` + +### 7.6 目录规划建议 + +- `incoming/` 原始上传文件 +- `tasks/` 任务工作目录 +- `outputs/pdf/` OCR 输出 +- `outputs/markdown/` Markdown 输出 +- `logs/` 服务日志与任务日志 + +### 7.7 广播能力建议 + +mDNS TXT Record 中建议广播: + +- `stream=1` +- `upload=1` +- `process=1` +- `download=1` +- `apiVersion=1` + +如需更细粒度,也可通过 `GET /health` 返回: + +```json +{ + "name": "FairScan-PC-Office", + "status": "ok", + "features": ["stream", "upload", "process", "download"], + "processTypes": ["markdown", "ocrpdf"] +} +``` + +--- + +## 8. 风险与难点 + +## 8.1 实时图传的延迟与稳定性 + +风险: + +- CameraX 分析 + 压缩 + 网络发送会叠加延迟 +- 发送队列积压会使画面越来越慢 + +应对: + +- 固定抽帧 +- 只保留最新帧 +- 上一帧未发完则直接丢弃当前帧 +- 后台线程压缩与发送 +- 优先保证低延迟而不是高保真 + +## 8.2 Android 网络与 Wi‑Fi 限制 + +风险: + +- 获取 Wi‑Fi 名称和网络信息在新系统上限制较多 +- App 内切换 Wi‑Fi 成本高且兼容性差 + +应对: + +- 不在 App 内切换 Wi‑Fi +- 只显示网络信息 +- 必要时提供“打开系统 Wi‑Fi 设置”入口 + +## 8.3 局域网发现兼容性 + +风险: + +- 某些路由器可能屏蔽 mDNS 多播 +- Windows 防火墙可能阻止服务广播 +- 无线与有线终端可能被隔离 + +应对: + +- 使用 `NSD + 手动输入 + /health 校验` 三段式兜底 +- 设置页提供简单排障提示 +- 后续再评估 UDP/网段探测 + +## 8.4 PC 后处理环境依赖 + +风险: + +- MinerU 与 OCRmyPDF 依赖较重 +- Python 环境与外部工具安装复杂 +- 内部执行器后续可能替换实现 + +应对: + +- Android 端只依赖统一处理接口,不依赖具体工具细节 +- PC 端先稳定接口契约,再逐步补充内部执行器 +- 通过 `processTypes` 或等价字段声明当前支持的处理类型 + +## 8.5 结果回到手机的可靠性 + +风险: + +- 任务成功但下载失败 +- 用户切到后台后任务状态丢失 +- 下载到本地后存储路径不清晰 + +应对: + +- 区分“任务失败”和“下载失败” +- 记录最近任务与产物信息 +- 下载后提供打开、分享、另存为入口 + +--- + +## 9. 建议的任务清单(可直接进入开发) + +## P0:局域网发现与基础连接 + +### Task P0-1:扩展设置仓库与设置页 + +- 增加 PC 主机地址和端口配置 +- 增加发现结果展示区域 +- 增加“扫描局域网主机”按钮 +- 增加图传质量配置 +- 增加默认处理类型配置 +- 增加自动下载结果开关 +- 显示手机当前 IP + +### Task P0-2:实现局域网发现基础能力 + +- 接入 Android NSD +- 定义 `DiscoveredHost` 与 `DiscoveryState` +- 扫描 `_fairscan._tcp` +- 发现后支持一键填充 host/port +- 失败时保留手动输入兜底 + +### Task P0-3:补充网络基础设施 + +- 增加网络权限 +- 增加基础 HTTP 客户端 +- 增加统一错误模型 +- 增加 `GET /health` 校验 + +### Task P0-4:实现 PC 最小服务骨架 + +- `GET /health` +- mDNS 注册 `_fairscan._tcp` +- 广播服务能力信息 + +## P1:实时图传 + +### Task P1-1:实现图传状态模型 + +- 未连接 +- 连接中 +- 已连接 +- 发送中 +- 出错 + +### Task P1-2:实现帧压缩与抽帧策略 + +- 低/中/高三档 +- 限帧率 +- 丢帧策略 +- 只保留最新帧 + +### Task P1-3:相机页接入图传控制 + +- 开关 +- 状态提示 +- 主机信息展示 +- 复用已发现或已保存的目标主机 + +### Task P1-4:实现 PC 端实时预览 + +- `WS /stream` +- 页面或桌面窗口显示 + +### Task P1-5:图传与发现联动 + +- 直接选择发现到的主机作为图传目标 +- 根据 `features` 判断主机是否支持 `stream` + +## P2:手机本地 PDF 上传 + +### Task P2-1:实现 PDF 上传客户端 + +- 导出页新增“发送到 PC”按钮 +- 生成 PDF 后发给 PC 服务 +- 展示上传进度和结果 + +### Task P2-2:联调上传闭环 + +- 手机扫描文档 +- 手机本地生成 PDF +- 上传到 PC 成功 +- 手动输入和自动发现两条路径都可工作 + +## P3:统一处理任务与结果下载 + +### Task P3-1:实现统一任务接口客户端 + +- 支持 `processType=markdown` +- 支持 `processType=ocrpdf` +- 统一查询任务状态 +- 统一查询产物列表 + +### Task P3-2:实现统一结果下载流程 + +- 下载 `.md` 结果 +- 下载 `.pdf` 结果 +- 基于 `mimeType` 决定本地处理方式 + +### Task P3-3:统一任务状态 UI + +- 等待中 +- 处理中 +- 成功 +- 失败 +- 下载中 +- 下载成功 +- 下载失败 + +### Task P3-4:PC 端统一接口占位实现 + +- `POST /tasks/process` +- `GET /tasks/{id}` +- `GET /tasks/{id}/artifacts` +- `GET /artifacts/{artifactId}/download` +- 先允许返回 mock 或占位结果,再逐步接入真实处理器 + +## P4:体验优化 + +### Task P4-1:发现结果去重与缓存 + +- 按 service name 或 host:port 去重 +- 记住最近选择设备 +- 显示最近发现时间 + +### Task P4-2:后台任务与通知 + +- 上传后台继续 +- 处理结果通知 +- 下载结果通知 + +### Task P4-3:高级兜底方案评估 + +- UDP 广播发现 +- 网段 HTTP 探测 +- 仅在主方案不稳定时启用 + +--- + +## 10. 建议的验收用例 + +### 用例 1:局域网发现 + +- PC 服务启动并广播 `_fairscan._tcp` +- 手机点击“扫描局域网主机” +- 手机看到可用设备列表 +- 用户选择设备后自动填充 host/port +- `GET /health` 验证通过 + +### 用例 2:实时图传 + +- 手机开启图传 +- PC 端能实时看到画面 +- 切换压缩档位后画面质量与延迟明显变化 +- 图传关闭后不影响正常扫描 + +### 用例 3:手机本地 PDF 上传 + +- 手机按原方案扫描 3 页文档 +- 手机本地生成 PDF +- 上传到 PC 成功 +- PC 成功保存原始 PDF + +### 用例 4:统一处理接口返回 Markdown 结果 + +- 手机使用 `processType=markdown` 发起任务 +- PC 返回任务状态 +- PC 提供 `.md` 结果下载 +- 手机成功下载 Markdown 结果 + +### 用例 5:统一处理接口返回 OCR PDF 结果 + +- 手机使用 `processType=ocrpdf` 发起任务 +- PC 返回任务状态 +- PC 提供 `.pdf` 结果下载 +- 手机成功下载 OCR 后 PDF + +### 用例 6:异常处理 + +- 主机地址错误 +- PC 服务未启动 +- mDNS 发现失败 +- 上传中断网 +- 当前 `processType` 不受支持 +- 下载结果失败 +- UI 都能给出明确失败信息 + +--- + +## 11. 建议的实现优先级 + +推荐开发顺序: + +1. 主机发现 + 健康检查 +2. 实时图传 +3. 手机本地 PDF 上传到 PC +4. 统一处理任务接口与结果下载 +5. 发现机制与任务体验优化 + +理由: + +- 你当前最关心的是实时图传,所以图传优先 +- 图传与文档处理解耦,优先完成图传不会阻碍后续 PDF 链路 +- PC 端后处理细节未来可替换,因此应先稳定统一接口契约 +- 文档处理链路应尽量让 Android 端只依赖抽象接口,不依赖具体工具内部实现 + +一句话概括: + +- **先把“实时看画面”做好,再把“正式处理文档并回到手机”做好;PC 内部处理器可以后补,但统一接口要先定下来。** + +--- + +## 12. 对执行者的说明 + +本节是写给任何执行这份文档的人或 AI 的。 + +### 12.1 不要误解的点 + +- “局域网相机”不是文档处理功能,而是实时图传功能。 +- 文档处理输入来自手机本地正式生成的 PDF,而不是图传流。 +- PC 端现阶段最重要的是统一接口契约,不是先把 MinerU/OCRmyPDF 完整接好。 +- Android 端应依赖统一处理接口,不要直接写死两套后处理协议。 + +### 12.2 可以接受的实现策略 + +- PC 端统一处理接口可以先返回 mock 结果 +- 只要契约稳定,内部执行器可以后续逐步替换成真实 MinerU / OCRmyPDF +- Android 端先把: + - 上传 + - 创建任务 + - 查询状态 + - 下载产物 + 的全链路走通即可 + +### 12.3 不建议的实现策略 + +- 不要把图传流直接接到 MinerU / OCRmyPDF +- 不要在 App 内做 Wi‑Fi 切换 +- 不要同时为 `markdown` 与 `ocrpdf` 设计两套完全独立的客户端协议 +- 不要让 Android 端过早耦合 PC 内部处理器实现细节 + +### 12.4 如果由 AI 编码代理执行 + +执行顺序建议严格按以下优先级推进: + +1. 发现与连接 +2. 实时图传 +3. PDF 上传 +4. 统一处理接口 +5. 结果下载 +6. 再考虑真实接入 MinerU/OCRmyPDF + +如果 AI 只能做一部分任务,优先确保: + +- 接口稳定 +- 状态模型稳定 +- UI 路径清晰 +- 占位实现可联调 + +--- + +## 13. 与原始需求的映射关系 + +- “文件原有的离线扫描功能” + - 对应当前现有扫描/导出链路 +- “手机网络图传功能” + - 对应模块 B,且定位为低延迟实时图传 +- “局域网内压缩广播的实时网络摄像头” + - 对应模块 B,第一版建议做点对点实时图传 +- “压缩力度可选” + - 对应图传质量预设 +- “离线扫描 PDF 通过 Wi‑Fi 协议发送给 PC” + - 对应模块 C +- “显示自己的 IP 和端口” + - 对应模块 A +- “MinerU 转成 Markdown” + - 对应统一处理接口中的 `processType=markdown` +- “OCRmyPDF 转成双层 PDF” + - 对应统一处理接口中的 `processType=ocrpdf` +- “PC 处理完毕后再传回手机” + - 对应模块 E,推荐技术实现为手机主动下载结果 diff --git a/requirements/mineru-integration.md b/requirements/mineru-integration.md new file mode 100644 index 0000000..0bf7adc --- /dev/null +++ b/requirements/mineru-integration.md @@ -0,0 +1,392 @@ +# MinerU 接入 FairScan PC Server 对接文档 + +> 本文档记录 MinerU 在本机的环境信息、API 用法,以及如何将其接入 FairScan PC 服务器, +> 替换当前的模拟处理逻辑。 + +--- + +## 1. 本机环境信息 + +> **统一环境**:MinerU 和 OCRmyPDF 共用一个 conda 环境 `MinerU`, +> PC 服务器始终通过 `conda activate MinerU` 启动。 + +| 项目 | 值 | +|------|-----| +| MinerU 源码路径 | `F:/datasets_rm/MinerU/` | +| **已安装版本** | **3.0.9** | +| **最新版本** | **3.2.2**(446 commits 差距) | +| Conda 环境 | `D:/ProgramData/miniconda3/envs/MinerU/` | +| Python | 3.10.20 | +| PyTorch | 2.6.0+cu124 | +| CUDA | 12.4 | +| GPU | NVIDIA GeForce RTX 4060 Laptop GPU (8 GB VRAM) | +| Transformers | 4.57.6 | +| onnxruntime | 1.23.2 | +| Pipeline 模型 | ✅ 已下载(HF cache: `C:/Users/32892/.cache/huggingface/hub/models--opendatalab--PDF-Extract-Kit-1.0`) | +| VLM 模型 | ✅ 已下载(HF cache: `C:/Users/32892/.cache/huggingface/hub/models--opendatalab--MinerU2.5-2509-1.2B`) | +| HF Hub 离线模式 | ✅ `HF_HUB_OFFLINE=1`(main.py 启动时设置) | +| OCRmyPDF | ✅ v15.4.4 已安装(源码 `F:/datasets_rm/ocRmypdf`,同一 conda 环境) | +| Tesseract | ❌ 待安装(OCRmyPDF 必需依赖) | + +--- + +## 2. 前置准备:升级 MinerU(强烈建议) + +当前安装的 3.0.9 与最新 3.2.2 差距较大(446 commits),主要改进包括: + +- **`aio_do_parse()` 异步接口** — 可直接 await 调用,不阻塞 FastAPI 事件循环 +- **并发锁优化** — Layout/MFR/OCR 使用独立推理锁,减少 GPU 争用 +- **PDF 渲染修复** — 大量 PDFium 资源泄漏和崩溃修复 +- **图像分析** — 新增 `image_analysis` 参数 +- **Client-side 输出生成** — 新增 `client_side_output_generation` 选项 + +### 2.1 拉取最新代码 + +```bash +cd F:/datasets_rm/MinerU +git checkout main +git pull origin main +git checkout mineru-3.2.2-released +``` + +### 2.2 更新安装 + +```bash +conda activate MinerU +pip install -e . +``` + +### 2.3 验证 + +```bash +# 检查版本 +python -c "from mineru.version import __version__; print(__version__)" # 应为 3.2.2 + +# 验证模型可用 +python -c " +from mineru.utils.models_download_utils import auto_download_and_get_model_root_path +print('Pipeline:', auto_download_and_get_model_root_path('models/README.md', 'pipeline')) +print('VLM:', auto_download_and_get_model_root_path('/', 'vlm')) +" +``` + +> **注意**:如果之后需要用 `model_source=local` 指定自定义模型路径,才需要创建 `~/.mineru.json` 配置文件。默认的 HuggingFace 缓存模式不需要。 + +--- + +## 3. MinerU 编程接口 + +### 3.1 核心函数:`do_parse` + +```python +from mineru.cli.common import do_parse, read_fn +from mineru.utils.enum_class import MakeMode +from pathlib import Path + +def do_parse( + output_dir: str, # 输出目录路径 + pdf_file_names: list[str], # PDF 文件名列表(不含扩展名) + pdf_bytes_list: list[bytes], # PDF 文件字节列表 + p_lang_list: list[str], # 语言列表("ch", "en", "japan" 等) + backend: str = "pipeline", # "pipeline" | "vlm-auto-engine" | "hybrid-auto-engine" + parse_method: str = "auto", # "auto" | "txt" | "ocr" + formula_enable: bool = True, + table_enable: bool = True, + server_url: str | None = None, # 远程服务器 URL(仅 http-client 后端) + f_dump_md: bool = True, # 输出 .md 文件 + f_dump_middle_json: bool = True, # 输出 _middle.json + f_dump_model_output: bool = True, # 输出 _model.json + f_dump_orig_pdf: bool = True, # 输出原始 PDF 副本 + f_dump_content_list: bool = True, # 输出 _content_list.json + f_draw_layout_bbox: bool = True, # 输出带布局框的 PDF + f_draw_span_bbox: bool = True, # 输出带 span 框的 PDF + f_make_md_mode: MakeMode = MakeMode.MM_MD, # Markdown 模式 + start_page_id: int = 0, + end_page_id: int | None = None, # None = 所有页 + **kwargs, +) +``` + +### 3.2 `read_fn` 辅助函数 + +```python +from mineru.cli.common import read_fn + +# 读取 PDF 文件为 bytes +pdf_bytes = read_fn("F:/path/to/doc.pdf") + +# 也支持图片文件(自动转为 PDF bytes) +png_bytes = read_fn("scan.png") +``` + +### 3.3 输出目录结构 + +Pipeline 后端(`backend="pipeline"`)输出: + +``` +{output_dir}/ + {pdf_name}/ + auto/ # parse_method="auto" + {pdf_name}.md # ★ Markdown 输出(主要产物) + {pdf_name}_middle.json # 中间解析结果 + {pdf_name}_model.json # 模型原始输出 + {pdf_name}_content_list.json + {pdf_name}_origin.pdf # 原始 PDF 副本 + {pdf_name}_layout.pdf # 布局可视化 + {pdf_name}_span.pdf # Span 可视化 + images/ # 提取的图片 +``` + +### 3.4 语言代码 + +| 代码 | 语言 | +|------|------| +| `ch` | 简体中文 | +| `ch_server` | 中文服务器版(较快) | +| `ch_lite` | 中文轻量版 | +| `en` | 英语 | +| `japan` | 日语 | +| `korean` | 韩语 | +| `chinese_cht` | 繁体中文 | + +--- + +## 4. 接入方案 + +### 方案 A:直接异步 API 调用(强烈推荐,需 v3.2.2) + +升级到 v3.2.2 后,可以直接使用 `aio_do_parse()` — MinerU 原生异步接口,无需 `asyncio.to_thread()`。 + +**优点**: +- **原生 async**,直接 await,不阻塞 FastAPI 事件循环 +- 最简单,不需要进程间通信 +- 可直接获取输出文件路径 + +**前提**: +- FairScan PC 服务器在 MinerU conda 环境中运行 +- `F:/datasets_rm/MinerU` 已通过 `pip install -e .` 安装 + +**实现思路**: + +```python +# ---- pc-server/main.py 新增代码 ---- + +from pathlib import Path +from mineru.cli.common import aio_do_parse, read_fn + +async def real_mineru_processing(task_id: str): + """使用 MinerU 异步接口真实处理 PDF""" + task = tasks_db.get(task_id) + if task is None: + return + + file_name = task.get("fileName", "document.pdf") + base_name = Path(file_name).stem + upload_path = Path(task["uploadPath"]) + process_type = task.get("processType", "ocrpdf") + lang = task.get("options", {}).get("lang", "ch") + + task["status"] = "processing" + task["progress"] = 10 + task["message"] = "MinerU processing started..." + + output_dir = TASKS_DIR / task_id + output_dir.mkdir(exist_ok=True) + pdf_bytes = read_fn(upload_path) + + try: + if process_type == "markdown": + await aio_do_parse( + output_dir=str(output_dir), + pdf_file_names=[base_name], + pdf_bytes_list=[pdf_bytes], + p_lang_list=[lang], + backend="pipeline", + f_dump_md=True, + f_dump_middle_json=False, + f_dump_model_output=False, + f_dump_orig_pdf=False, + f_dump_content_list=False, + f_draw_layout_bbox=False, + f_draw_span_bbox=False, + ) + md_path = output_dir / base_name / "auto" / f"{base_name}.md" + if md_path.exists(): + art_id = str(uuid.uuid4()) + artifacts_db[task_id] = [{ + "artifactId": art_id, "fileName": f"{base_name}.md", + "fileSize": md_path.stat().st_size, "fileType": "md", + "filePath": str(md_path), + }] + artifacts_map[art_id] = artifacts_db[task_id][0] + task.update(status="completed", progress=100, + message="MinerU Markdown completed") + return + + elif process_type == "ocrpdf": + await aio_do_parse( + output_dir=str(output_dir), + pdf_file_names=[base_name], + pdf_bytes_list=[pdf_bytes], + p_lang_list=[lang], + backend="pipeline", + f_dump_md=False, + f_dump_middle_json=False, + f_dump_model_output=False, + f_dump_orig_pdf=False, + f_dump_content_list=False, + f_draw_layout_bbox=True, + f_draw_span_bbox=False, + ) + layout_pdf = output_dir / base_name / "auto" / f"{base_name}_layout.pdf" + if layout_pdf.exists(): + art_id = str(uuid.uuid4()) + artifacts_db[task_id] = [{ + "artifactId": art_id, "fileName": f"{base_name}_ocr.pdf", + "fileSize": layout_pdf.stat().st_size, "fileType": "pdf", + "filePath": str(layout_pdf), + }] + artifacts_map[art_id] = artifacts_db[task_id][0] + task.update(status="completed", progress=100, + message="OCR PDF completed") + return + + task["status"] = "failed" + task["message"] = "MinerU did not produce output" + + except Exception as e: + task["status"] = "failed" + task["message"] = f"MinerU error: {str(e)}" + logger.error(f"MinerU task {task_id} failed: {e}") +``` + +### 方案 B:子进程调用(备选) + +通过 `subprocess` 调用 `mineru` CLI: + +```python +import subprocess +import asyncio + +async def mineru_subprocess(task_id: str): + task = tasks_db[task_id] + upload_path = task["uploadPath"] + output_dir = TASKS_DIR / task_id + + cmd = [ + r"D:/ProgramData/miniconda3/envs/MinerU/python.exe", + "-m", "mineru.cli.client", + "-p", str(upload_path), + "-o", str(output_dir), + "-b", "pipeline", + "-l", "ch", + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + # 轮询进度(可选:监控 stdout 中的进度信息) + while True: + line = await proc.stdout.readline() + if not line: + break + # 解析进度... + + returncode = await proc.wait() + if returncode == 0: + task["status"] = "completed" + else: + task["status"] = "failed" +``` + +**优点**:进程隔离,MinerU 崩溃不影响 FairScan 服务。 +**缺点**:进度监控困难,需要 IPC。 + +### 方案 C:MinerU FastAPI 服务 + +运行 MinerU 自带的 FastAPI 服务 `mineru-api` 作为微服务,FairScan 通过 HTTP 调用。 + +这一方案与 pc-api-spec.md 中对原子服务的建议一致,但实现复杂度更高。 + +--- + +## 5. 与 pc-api-spec.md 的对应关系 + +根据接口规范,两种 `processType` 与 MinerU 的映射: + +| processType | MinerU 后端 | 输出文件 | 文件类型 | +|-------------|-----------|---------|---------| +| `markdown` | `backend="pipeline"` | `{name}.md` | `text/markdown` | +| `ocrpdf` | `backend="pipeline"` + `f_draw_layout_bbox=True` | `{name}_layout.pdf` | `application/pdf` | + +两种类型共用同一个 MinerU `do_parse` 调用,仅输出选项不同。 + +--- + +## 6. 接入步骤建议 + +### Step 1:升级 MinerU 到最新版 + +```bash +cd F:/datasets_rm/MinerU +git checkout main && git pull origin main +git checkout mineru-3.2.2-released +conda activate MinerU +pip install -e . +``` + +验证: +```bash +python -c "from mineru.cli.common import aio_do_parse; print('OK')" +``` + +### Step 2:切换 PC 服务器运行环境 + +```bash +conda activate MinerU +cd E:/race_save/FairScan_cyy/FairScan/pc-server +python main.py +``` + +### Step 3:替换 `simulate_processing` 为真实 MinerU 调用 + +在 `main.py` 中将 `simulate_processing` 替换为 `real_mineru_processing`(参考方案 A 的实现)。 + +### Step 4:端到端测试 + +1. 用小 PDF(1-2 页)先用 `parse_method="txt"` 测试(速度快) +2. 确认无误后切换为 `parse_method="auto"`(完整 OCR+公式+表格) +3. 测试处理完成后产物下载 + +--- + +## 7. 注意事项 + +| 项目 | 说明 | +|------|------| +| **GPU 显存** | RTX 4060 有 8GB VRAM。pipeline 后端约需 4-6GB,VLM 后端约需 6-8GB。建议用 pipeline 后端。 | +| **处理速度** | 普通 A4 PDF,pipeline 后端约 3-8 秒/页(取决于内容复杂度)。 | +| **语言** | 默认传 `ch`(简体中文)。FairScan 可扩展语言选择功能。 | +| **页数限制** | 可用 `start_page_id` / `end_page_id` 限制处理范围。 | +| **大文件** | PDF > 100 页建议分批处理。 | +| **超时** | 单次处理时间与页数成正比,不要设置过短的 HTTP 超时。 | +| **锁模型** | `do_parse` 不是线程安全的。FastAPI 的 `async` 端点应在线程池中调用,避免阻塞事件循环。 | +| **错误处理** | `do_parse` 出错会抛出异常,需捕获并设置 `task["status"] = "failed"`。 | + +--- + +## 8. 关键参考文件 + +| 文件 | 说明 | +|------|------| +| `F:/datasets_rm/MinerU/mineru/cli/common.py` | `do_parse()` 主入口 | +| `F:/datasets_rm/MinerU/mineru/cli/client.py` | CLI 参数定义 | +| `F:/datasets_rm/MinerU/mineru/cli/output_paths.py` | 输出路径解析 | +| `F:/datasets_rm/MinerU/mineru/utils/config_reader.py` | 配置读取 | +| `F:/datasets_rm/MinerU/mineru/utils/enum_class.py` | 枚举类型定义 | +| `F:/datasets_rm/MinerU/mineru.template.json` | 配置文件模板 | +| `E:/race_save/FairScan_cyy/FairScan/pc-server/main.py` | FairScan PC 服务器(需修改) | +| `E:/race_save/FairScan_cyy/FairScan/requirements/pc-api-spec.md` | API 接口规范 | diff --git a/requirements/pc-api-spec.md b/requirements/pc-api-spec.md new file mode 100644 index 0000000..389424e --- /dev/null +++ b/requirements/pc-api-spec.md @@ -0,0 +1,789 @@ +# FairScan PC 端统一接口规范(草案 v0.1) + +> 本文档定义 FairScan 手机端与 PC 端之间的最小稳定接口契约。 +> +> 适用对象: +> +> - 人工开发者 +> - Claude Code +> - 其他 AI 编码代理 +> +> 设计目标: +> +> - 让不同执行者都能按同一接口实现,不因上下文差异而跑偏 +> - 优先稳定协议与字段,而不是优先绑定具体内部实现 +> - 允许 PC 端先做“接口占位实现”,后续再逐步接入真实 MinerU / OCRmyPDF + +--- + +## 1. 设计范围 + +本文档覆盖以下能力: + +1. 局域网服务发现配套信息 +2. 健康检查接口 +3. 实时图传接口 +4. PDF 上传接口 +5. 统一处理任务接口 +6. 任务状态查询接口 +7. 处理产物查询接口 +8. 处理产物下载接口 + +本文档**不**约束以下内容: + +- PC 端内部具体使用什么库执行 MinerU +- PC 端内部具体使用什么方式调用 OCRmyPDF +- PC 端图传画面最终是显示在网页、桌面窗口还是其他 UI 中 +- Android 端 UI 的具体布局样式 + +也就是说: + +- **本文档约束的是“外部协议”** +- **不强制约束“内部实现”** + +--- + +## 2. 核心原则 + +### 2.1 图传与文档处理解耦 + +- 实时图传只负责低延迟画面预览 +- 正式文档处理只基于手机本地生成的 PDF +- 图传流不得直接作为 MinerU / OCRmyPDF 的正式输入 + +### 2.2 统一处理接口 + +PC 端后处理统一使用一套任务接口。 + +支持的处理类型: + +- `markdown` +- `ocrpdf` + +差异只体现在: + +- `processType` +- 返回产物的 MIME 类型 + +### 2.3 手机主动下载结果 + +“PC 处理后结果回到手机”在工程上定义为: + +- 手机查询任务状态 +- 手机获取产物列表 +- 手机主动下载产物 + +不要求 PC 主动回连手机进行推送。 + +### 2.4 允许占位实现 + +第一阶段允许 PC 端: + +- 返回 mock 任务 +- 返回 mock 产物 +- 先不真正接入 MinerU / OCRmyPDF + +只要对外接口契约稳定即可。 + +--- + +## 3. 术语定义 + +### 3.1 File + +指手机上传到 PC 的原始 PDF 文件。 + +### 3.2 Task + +指 PC 端异步处理任务。 + +### 3.3 Artifact + +指任务完成后可下载的结果文件。 + +### 3.4 Primary Artifact + +指该处理类型最核心的主产物: + +- `markdown` -> `.md` +- `ocrpdf` -> `.pdf` + +### 3.5 Auxiliary Artifact + +指附加产物,例如: + +- 资源图片 +- 日志文件 +- JSON 中间结果 +- 识别报告 + +--- + +## 4. 协议总览 + +| 能力 | 方法 | 路径 | 说明 | +|---|---|---|---| +| 健康检查 | GET | `/health` | 检查服务可用性与能力 | +| 实时图传 | WS | `/stream` | 接收手机实时图像帧 | +| 上传 PDF | POST | `/upload/pdf` | 上传正式文档 PDF | +| 创建处理任务 | POST | `/tasks/process` | 发起统一处理任务 | +| 查询任务状态 | GET | `/tasks/{taskId}` | 查询任务执行状态 | +| 查询任务产物 | GET | `/tasks/{taskId}/artifacts` | 获取结果文件列表 | +| 下载产物 | GET | `/artifacts/{artifactId}/download` | 下载结果文件 | +| 下载原始文件 | GET | `/files/{fileId}/download` | 下载已上传的原始 PDF | + +默认基础地址示例: + +```text +http://{host}:{port} +``` + +例如: + +```text +http://192.168.1.10:8080 +``` + +--- + +## 5. 通用约定 + +### 5.1 编码与格式 + +- JSON 请求与响应统一使用 UTF-8 +- 除下载接口外,默认返回 `application/json` +- 图传 WebSocket 使用二进制消息承载 JPEG 帧 + +### 5.2 ID 规则 + +以下字段都视为**不透明字符串**: + +- `fileId` +- `taskId` +- `artifactId` + +客户端不得依赖这些 ID 的内部结构。 + +### 5.3 时间字段 + +如果服务返回时间字段,建议使用 RFC 3339 / ISO 8601,例如: + +```text +2026-06-04T12:34:56Z +``` + +时间字段不是第一阶段强制要求,但如果提供,应统一格式。 + +### 5.4 状态枚举 + +任务状态建议使用以下枚举: + +```text +queued +running +completed +failed +``` + +如后续需要,可扩展: + +```text +canceled +``` + +### 5.5 错误返回格式 + +推荐所有错误统一返回: + +```json +{ + "error": { + "code": "INVALID_REQUEST", + "message": "processType is required" + } +} +``` + +推荐错误码: + +- `INVALID_REQUEST` +- `UNSUPPORTED_PROCESS_TYPE` +- `FILE_NOT_FOUND` +- `TASK_NOT_FOUND` +- `ARTIFACT_NOT_FOUND` +- `PROCESSING_FAILED` +- `SERVICE_UNAVAILABLE` + +### 5.6 版本兼容原则 + +- 第一阶段不强制引入 `/api/v1` 路径前缀 +- 通过 `apiVersion` 字段表达协议版本 +- 后续如需重大变更,再评估路径版本化 + +--- + +## 6. 局域网发现配套约定 + +### 6.1 mDNS 服务标识 + +- service type:`_fairscan._tcp` +- service instance name:`FairScan-PC-{deviceName}` + +### 6.2 推荐 TXT Record 字段 + +- `name`:设备显示名 +- `features`:`stream,upload,process,download` +- `apiVersion`:如 `1` +- `version`:PC 服务版本 + +### 6.3 关于 `process` 能力 + +这里建议广播能力使用: + +- `process` + +而不是直接广播多个内部工具名。 + +原因: + +- 发现层只需表达“能不能处理” +- 具体支持哪些 `processType`,可通过 `/health` 返回 +- 这样后续新增其他处理器时不需要修改发现层语义 + +--- + +## 7. 健康检查接口 + +## 7.1 GET `/health` + +### 作用 + +- 判断服务是否在线 +- 返回最小能力信息 +- 返回支持的处理类型 + +### 请求 + +无请求体。 + +### 成功响应示例 + +```json +{ + "name": "FairScan-PC-Office", + "status": "ok", + "version": "0.1.0", + "apiVersion": "1", + "features": ["stream", "upload", "process", "download"], + "processTypes": ["markdown", "ocrpdf"] +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `name` | string | 是 | 设备显示名 | +| `status` | string | 是 | 固定为 `ok` | +| `version` | string | 否 | PC 服务版本 | +| `apiVersion` | string | 是 | 接口版本 | +| `features` | string[] | 是 | 服务能力 | +| `processTypes` | string[] | 否 | 当前支持的处理类型 | + +### 状态码 + +- `200 OK` + +--- + +## 8. 实时图传接口 + +## 8.1 WS `/stream` + +### 作用 + +接收手机端发送的实时画面帧。 + +### 连接方式 + +- 客户端发起 WebSocket 连接 +- 连接成功后开始发送二进制帧 +- 每条二进制消息代表**一张完整 JPEG 图像** + +### 帧格式 + +- 二进制消息 +- 内容:JPEG 文件完整字节流 +- 一条消息 = 一帧 + +### 服务端要求 + +- 服务端可只保留最新帧 +- 服务端不要求逐帧确认 +- 服务端允许丢弃旧帧以保证实时性 + +### 客户端要求 + +- 不得无限积压待发送帧 +- 若上一帧尚未发完,允许直接丢弃当前帧 +- 连接断开后由客户端自行决定是否重连 + +### 第一阶段最小可接受行为 + +- 服务端只需能接收 JPEG 帧并显示或缓存最新一帧 +- 不要求复杂多端会话管理 +- 不要求录像、回放、时间轴等高级功能 + +### 状态码 + +- WebSocket Upgrade 成功即视为可用 + +--- + +## 9. PDF 上传接口 + +## 9.1 POST `/upload/pdf` + +### 作用 + +上传手机端正式生成的 PDF 文件。 + +### 请求类型 + +```text +multipart/form-data +``` + +### 表单字段 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file` | file | 是 | PDF 文件 | + +### 约束 + +- `file` 的 MIME 类型应为 `application/pdf` +- 服务端可根据需要限制上传大小 +- 若文件过大,建议返回 `413 Payload Too Large` + +### 成功响应示例 + +```json +{ + "fileId": "file-123", + "fileName": "Scan 2026-06-04 12.34.56.pdf", + "mimeType": "application/pdf", + "sizeBytes": 1048576 +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `fileId` | string | 是 | 服务端文件标识 | +| `fileName` | string | 是 | 保存后的文件名 | +| `mimeType` | string | 是 | 固定为 `application/pdf` | +| `sizeBytes` | number | 是 | 文件字节大小 | + +### 状态码 + +- `201 Created` +- `400 Bad Request` +- `413 Payload Too Large` +- `500 Internal Server Error` + +--- + +## 10. 统一处理任务接口 + +## 10.1 POST `/tasks/process` + +### 作用 + +使用统一接口发起后处理任务。 + +### 请求示例 + +```json +{ + "fileId": "file-123", + "processType": "markdown", + "options": {} +} +``` + +### 请求字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `fileId` | string | 是 | 由上传接口返回的文件标识 | +| `processType` | string | 是 | `markdown` 或 `ocrpdf` | +| `options` | object | 否 | 预留扩展字段,首版可为空对象 | + +### 处理类型定义 + +| `processType` | 含义 | 预期主产物 | +|---|---|---| +| `markdown` | 执行 Markdown 转换 | `text/markdown` | +| `ocrpdf` | 执行 OCR PDF 处理 | `application/pdf` | + +### 成功响应示例 + +```json +{ + "taskId": "task-123", + "status": "queued", + "processType": "markdown", + "fileId": "file-123" +} +``` + +### 状态码 + +- `202 Accepted` +- `400 Bad Request` +- `404 Not Found`(`fileId` 不存在) +- `422 Unprocessable Entity`(`processType` 不支持时可选) +- `500 Internal Server Error` + +### 第一阶段占位实现要求 + +如果真实 MinerU / OCRmyPDF 尚未接入,允许这样实现: + +- 接口正常收请求 +- 正常返回 `taskId` +- 任务状态可直接从 `queued` -> `completed` +- 产物可先返回 mock 文件或占位文件 + +这样做的目标是: + +- 先稳定客户端协议 +- 先打通 Android 联调链路 +- 后续再逐步替换成真实处理器 + +--- + +## 11. 查询任务状态接口 + +## 11.1 GET `/tasks/{taskId}` + +### 作用 + +返回单个任务的当前状态。 + +### 成功响应示例 + +```json +{ + "taskId": "task-123", + "status": "running", + "processType": "markdown", + "fileId": "file-123", + "progress": 50, + "message": "processing", + "artifactsAvailable": false +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `taskId` | string | 是 | 任务标识 | +| `status` | string | 是 | `queued` / `running` / `completed` / `failed` | +| `processType` | string | 是 | 任务处理类型 | +| `fileId` | string | 是 | 输入文件标识 | +| `progress` | number | 否 | 建议 0~100 | +| `message` | string | 否 | 当前状态说明 | +| `artifactsAvailable` | boolean | 否 | 是否已有可下载产物 | + +### 失败响应示例 + +```json +{ + "error": { + "code": "TASK_NOT_FOUND", + "message": "task not found" + } +} +``` + +### 状态码 + +- `200 OK` +- `404 Not Found` + +--- + +## 12. 查询任务产物接口 + +## 12.1 GET `/tasks/{taskId}/artifacts` + +### 作用 + +列出某个任务已经生成的所有产物。 + +### 成功响应示例 + +#### Markdown 任务示例 + +```json +[ + { + "artifactId": "artifact-1", + "fileName": "result.md", + "mimeType": "text/markdown", + "role": "primary", + "sizeBytes": 2048 + } +] +``` + +#### OCR PDF 任务示例 + +```json +[ + { + "artifactId": "artifact-2", + "fileName": "result.pdf", + "mimeType": "application/pdf", + "role": "primary", + "sizeBytes": 3145728 + } +] +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `artifactId` | string | 是 | 产物标识 | +| `fileName` | string | 是 | 文件名 | +| `mimeType` | string | 是 | MIME 类型 | +| `role` | string | 是 | `primary` / `auxiliary` / `log` | +| `sizeBytes` | number | 否 | 文件大小 | + +### 约束 + +- 对 `markdown`,应至少存在一个 `role=primary` 且 `mimeType=text/markdown` 的产物 +- 对 `ocrpdf`,应至少存在一个 `role=primary` 且 `mimeType=application/pdf` 的产物 + +### 状态码 + +- `200 OK` +- `404 Not Found` + +--- + +## 13. 产物下载接口 + +## 13.1 GET `/artifacts/{artifactId}/download` + +### 作用 + +下载指定产物文件。 + +### 响应 + +- 响应体为二进制文件流 +- `Content-Type` 应与产物 `mimeType` 一致 +- `Content-Disposition` 建议包含文件名 + +### 成功行为示例 + +- 下载 Markdown:`Content-Type: text/markdown` +- 下载 OCR PDF:`Content-Type: application/pdf` + +### 状态码 + +- `200 OK` +- `404 Not Found` + +--- + +## 14. 原始文件下载接口 + +## 14.1 GET `/files/{fileId}/download` + +### 作用 + +下载已上传但尚未处理的原始 PDF 文件。 + +### 响应 + +- 响应体为二进制文件流 +- `Content-Type: application/pdf` +- `Content-Disposition` 包含原始文件名 + +### 典型用途 + +- PC 管理面板中直接下载查看手机上传的原始 PDF +- 手机端重新获取已上传的文件 + +### 状态码 + +- `200 OK` +- `404 Not Found`(文件 ID 不存在或文件已从磁盘删除) + +--- + +## 15. 两类处理任务的差异说明 + +## 15.1 `processType=markdown` + +### 目标 + +把 PDF 处理为 Markdown 文档。 + +### 最低要求 + +- 至少返回一个 `.md` 主产物 + +### 可选附加产物 + +- 图片资源 +- 日志 +- JSON 中间结果 + +## 15.2 `processType=ocrpdf` + +### 目标 + +把 PDF 处理为 OCR 后的可搜索 PDF。 + +### 最低要求 + +- 至少返回一个 `.pdf` 主产物 + +### 可选附加产物 + +- 日志 +- 识别报告 + +--- + +## 16. 典型调用流程 + +## 16.1 实时图传流程 + +1. 手机发现并选择 PC 主机 +2. 手机调用 `/health` 确认支持 `stream` +3. 手机建立 `WS /stream` +4. 手机按抽帧策略发送 JPEG 帧 +5. PC 实时显示最新帧 + +## 16.2 文档处理流程 + +1. 手机本地生成 PDF +2. 手机 `POST /upload/pdf` +3. 手机获得 `fileId` +4. 手机 `POST /tasks/process` +5. 手机获得 `taskId` +6. 手机轮询 `GET /tasks/{taskId}` +7. 任务完成后,手机调用 `GET /tasks/{taskId}/artifacts` +8. 手机调用 `GET /artifacts/{artifactId}/download` +9. 手机保存、打开或分享结果 + +--- + +## 17. 第一阶段可接受的占位实现 + +如果当前目标只是让 Android 端和 PC 端先联调通,这一阶段允许: + +### 17.1 `markdown` 占位实现 + +- 收到 `processType=markdown` +- 直接生成一个示例 `.md` 文件 +- 任务短时间内进入 `completed` + +### 17.2 `ocrpdf` 占位实现 + +- 收到 `processType=ocrpdf` +- 直接复制输入 PDF 为新文件,或生成一个占位 PDF +- 任务短时间内进入 `completed` + +### 17.3 为什么允许这样做 + +这样可以先验证: + +- 接口字段是否稳定 +- Android 端状态流是否完整 +- 下载逻辑是否可用 +- 不同 `mimeType` 的本地处理是否正确 + +等这些都稳定后,再接入真实处理器更安全。 + +--- + +## 18. 对执行者的约束说明 + +本节适用于任何执行这份接口文档的人或 AI。 + +### 18.1 必须遵守的约束 + +- 不要为 `markdown` 和 `ocrpdf` 设计两套独立任务协议 +- 不要让 Android 端依赖 PC 内部执行器实现细节 +- 不要把图传流直接作为正式文档处理输入 +- 不要把“结果回到手机”实现成 PC 主动推送手机的唯一方式 + +### 18.2 优先级建议 + +如果执行资源有限,优先实现: + +1. `/health` +2. `/upload/pdf` +3. `/tasks/process` +4. `/tasks/{id}` +5. `/tasks/{id}/artifacts` +6. `/artifacts/{artifactId}/download` +7. `WS /stream` + +说明: + +- 如果当前主要目标是联调文档处理链路,可先暂缓图传 UI +- 如果当前主要目标是实时性验证,可先实现 `WS /stream` +- 但无论如何,统一处理接口契约应保持不变 + +--- + +## 19. 与 Android 端实现的对应关系 + +PC 接口与 Android 模块建议对应如下: + +| PC 接口 | Android 模块 | +|---|---| +| `/health` | discovery / server endpoint | +| `WS /stream` | stream client | +| `/upload/pdf` | upload client | +| `/tasks/process` | task client | +| `/tasks/{id}` | task polling logic | +| `/tasks/{id}/artifacts` | artifact query logic | +| `/artifacts/{artifactId}/download` | artifact download client | +| `/files/{fileId}/download` | raw file download client | + +--- + +## 20. 后续扩展预留 + +后续如果需要扩展,可在不破坏主契约的情况下增加: + +- 更多 `processType` +- 更多 `options` 字段 +- 任务取消接口 +- 批量任务接口 +- 任务日志查询接口 +- 结果 ZIP 打包下载接口 + +但第一阶段不建议过早加入这些扩展。 + +--- + +## 21. 一句话总结 + +这份接口规范的核心思想是: + +- **实时图传走一条轻量、低延迟链路** +- **文档处理走一条统一任务接口链路** +- **MinerU 与 OCRmyPDF 共用同一处理协议,只通过 `processType` 区分** +- **允许先用占位实现把联调跑通,再逐步接入真实处理器** diff --git a/requirements/requirements.md b/requirements/requirements.md new file mode 100644 index 0000000..940fac0 --- /dev/null +++ b/requirements/requirements.md @@ -0,0 +1,108 @@ +# FairScan + +> 此文档为项目需求文档 + +## 文件原有的离线扫描功能 + +- 相机实时预览、文档边缘检测、自动裁切 +- 页面编辑(裁切/旋转/滤镜/顺序调整) +- PDF/JPEG 导出 +- 多页扫描管理 + +## 手机网络图传功能 + +### 变成一个局域网内进行一定压缩广播的实时网络摄像头 + +- 手机端通过 WebSocket 将 JPEG 帧发送到 PC +- PC 端浏览器实时显示画面 +- 支持帧率控制(无限制 / 15fps / 10fps / 5fps) +- 丢帧策略:上一帧未发送完毕则丢弃当前帧,保证实时性 +- 连接状态显示(已连接/未连接/出错) + +#### 压缩力度可选 + +- **低质量**:最长边 640px,JPEG 质量 45,目标 8~12fps +- **均衡**:最长边 960px,JPEG 质量 60,目标 6~10fps(默认) +- **高质量**:最长边 1280px,JPEG 质量 75,目标 5~8fps + +## 支持将离线扫描出来的pdf,通过局域网wifi网络协议发送给pc主机 + +### 已实现的核心功能 + +#### 1. PDF 上传 +- 手机端在导出页可选择"仅传输到电脑" +- 通过 HTTP multipart/form-data 上传到 PC 服务器 `POST /upload/pdf` +- 上传进度与状态实时显示 +- 上传成功后返回 `fileId`,PC 端保存原始 PDF 到 `./uploads/` 目录 + +#### 2. 上传+处理 +- 上传后自动创建处理任务:`POST /tasks/process` +- 支持两种处理类型: + - **OCR PDF** (`processType=ocrpdf`) — 复制原始 PDF 作为"处理结果" + - **Markdown** (`processType=markdown`) — 生成模拟 `.md` 文件 +- 任务状态轮询:queued → processing (10% → 50% → 90%) → completed +- 处理完成后可下载产物 + +#### 3. PC 端管理面板 +- 浏览器访问 `/dashboard` 查看管理界面 +- 统计卡片:已上传文件数、处理任务数、排队中/处理中/已完成 +- 文件列表:显示已上传的 PDF,支持下载原始文件 +- 任务列表:显示所有处理任务,支持下载处理产物 +- 自动刷新(每 2 秒) +- 导航栏:可在图传预览页和管理面板间切换 + +### 所连接的wifi可自定义,可以显示出自己的IP和端口 + +- 设置页可配置 PC 主机地址和端口 +- 支持手动输入 IP 和端口 +- 显示当前手机 IP 地址 +- 通过 `GET /health` 测试连接 +- 局域网发现(mDNS/NSD)的占位代码已准备,待完整实现 + +### MinerU转成markdowm便于数字化存储 ✅ 已实现 + +- `processType=markdown` 处理类型 +- 使用 MinerU `aio_do_parse()` 异步接口,pipeline 后端 +- `HF_HUB_OFFLINE=1` 使用本地缓存模型(绕过 huggingface.co 不可达) +- 输出产物:`.md` + `images/` + `{name}_result.zip`(ZIP 含 .md + images/) +- 手机端可通过任务管理面板查看状态并下载到指定目录 + +### 进行OCRmyPDF 转成双层pdf 📌 下一步 + +- `processType=ocrpdf` 处理类型 +- **当前**:使用 MinerU 生成 layout PDF(画布局框,非真正 OCR) +- **目标**:接入 `ocrmypdf` 库,生成可搜索双层 PDF +- 接口已预留,详见 `requirements/NEXT_STEPS.md` + +## PC 端服务器 + +基于 Python FastAPI,提供以下端点: + +| 端点 | 方法 | 功能 | +|------|------|------| +| `/health` | GET | 健康检查 | +| `/` | GET | 图传预览页面 | +| `/stream` | WS | 接收 JPEG 帧 | +| `/dashboard` | GET | 管理面板页面 | +| `/api/dashboard` | GET | 管理面板 JSON 数据 | +| `/upload/pdf` | POST | 上传 PDF(纯上传,不处理) | +| `/tasks/process` | POST | 创建处理任务 | +| `/tasks/{taskId}` | GET | 查询任务状态 | +| `/tasks/{taskId}/artifacts` | GET | 查询任务产物列表 | +| `/artifacts/{artifactId}/download` | GET | 下载处理产物 | +| `/files/{fileId}/download` | GET | 下载已上传的原始文件 | + +### 手机端任务管理面板 ✅ 已实现 + +- 导出页底部 `TaskPanelSection`:显示所有上传处理任务 +- 任务状态:排队中 / 处理中(进度条) / 已完成 / 失败 +- 2 秒间隔后台轮询,完成后自动停止 +- 已完成任务:选择下载目录(SAF)→ 下载产物 → 打开文件 +- Markdown 任务默认下载 ZIP(.md + images/),OCR PDF 任务下载 PDF + +## 后续待实现 + +- **P0 OCRmyPDF 真实接入**:用 `ocrmypdf` 库替换 MinerU layout PDF,产出可搜索双层 PDF +- **P0 局域网自动发现**:mDNS/NSD 自动发现 PC 服务 +- **处理结果自动下载**:配置开启后自动下载处理结果 +- **图传延迟/帧率实时显示**