From 48a10cbe378ac6bbf170cfb8734d9de5828a3d76 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:02:11 +0800 Subject: [PATCH 01/16] Add selectable translation progress effects --- mreader/ReaderView.swift | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 34f1fe93..32623945 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -135,6 +135,22 @@ nonisolated enum ImageFitMode: String, CaseIterable { case original } +nonisolated enum TranslationProgressEffect: String, CaseIterable, Sendable { + static let defaultsKey = "ai_translation_progress_effect" + + case bubblePulse + case marquee + + var localizedTitle: String { + switch self { + case .bubblePulse: + return "ocr.translationProgressEffect.bubblePulse".localized + case .marquee: + return "ocr.translationProgressEffect.marquee".localized + } + } +} + /// 缩放 / 平移的纯几何计算,独立于视图以便直接测试。 /// /// 关键点:图片**不一定**铺满 viewport。`fitScreen` / `fitHeight` 会留下上下或左右留白, @@ -1309,6 +1325,7 @@ struct ReaderView: View { @AppStorage("ocr_visual_verification_enabled") private var ocrVisualVerificationEnabled = false @AppStorage("ocr_local_recognition_mode") private var ocrRecognitionModeRaw = OCRRecognitionMode.adaptive.rawValue @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true + @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.bubblePulse.rawValue @AppStorage("translation_color_style") private var translationColorStyleRaw = TranslationColorStyle.contrast.rawValue @AppStorage("translation_use_apple_low_latency") private var useAppleLowLatency = false @AppStorage(MangaVisionHardCaseFeature.shortcutDefaultsKey) private var showMangaVisionFeedbackShortcut = false @@ -1566,7 +1583,9 @@ struct ReaderView: View { .ignoresSafeArea() } - if isAITranslationInProgress && aiTranslationBorderProgressEnabled { + if isAITranslationInProgress, + aiTranslationBorderProgressEnabled, + TranslationProgressEffect(rawValue: aiTranslationProgressEffectRaw) == .marquee { AppleIntelligenceGlowBorder( cornerRadius: 36, lineWidth: 18, @@ -1575,8 +1594,8 @@ struct ReaderView: View { colors: TranslationTextRenderer.palette ) .ignoresSafeArea() - .allowsHitTesting(false) - .transition(.opacity) + .allowsHitTesting(false) + .transition(.opacity) } TwoFingerSwipeDownDismissView( @@ -2252,6 +2271,14 @@ struct ReaderView: View { Toggle("ocr.borderProgress".localized, isOn: $aiTranslationBorderProgressEnabled) .disabled(!comic.isAITranslationEnabled) + Picker("ocr.translationProgressEffect".localized, selection: $aiTranslationProgressEffectRaw) { + ForEach(TranslationProgressEffect.allCases, id: \.rawValue) { effect in + Text(effect.localizedTitle).tag(effect.rawValue) + } + } + .pickerStyle(.menu) + .disabled(!comic.isAITranslationEnabled || !aiTranslationBorderProgressEnabled) + Picker("ocr.colorStyle".localized, selection: $translationColorStyleRaw) { ForEach(TranslationColorStyle.allCases, id: \.rawValue) { style in Text(style.title).tag(style.rawValue) From fbe642bbd7cbef3fa73b96858a195570fa8e1911 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:02:44 +0800 Subject: [PATCH 02/16] Restore original translation marquee appearance --- mreader/ReaderView.swift | 57 +++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 32623945..86eaea51 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -8000,7 +8000,10 @@ private struct AppleIntelligenceGlowBorder: View { let colors: [Color] var body: some View { - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in + // Restore the original layered marquee appearance that existed before + // ff1c87a4. The later three-stroke approximation was cheaper, but its + // thicker bands changed the visual character substantially. + TimelineView(.animation) { context in let duration = max(animationDuration, 0.1) let phase = context.date.timeIntervalSinceReferenceDate .truncatingRemainder(dividingBy: duration) / duration @@ -8008,45 +8011,45 @@ private struct AppleIntelligenceGlowBorder: View { let bandWidth = max(lineWidth, 1) let screenCornerRadius = adaptiveScreenCornerRadius(for: proxy.size) let outerCornerRadius = (screenCornerRadius > 0 ? screenCornerRadius : cornerRadius) + 5 - let outwardExpansion = max(bandWidth * 0.55, 3) - let shape = RoundedRectangle(cornerRadius: outerCornerRadius, style: .continuous) - let gradient = flowingGradient(phase: phase) + let layerCount = 36 + let layerWidth = bandWidth / CGFloat(layerCount) + let outwardExpansion = max(layerWidth * 1.5, 1) + let drawingSize = CGSize( + width: proxy.size.width + outwardExpansion * 2, + height: proxy.size.height + outwardExpansion * 2 + ) ZStack { - shape - .inset(by: outwardExpansion) - .strokeBorder( - gradient, - lineWidth: max(bandWidth * 0.72, 4), - antialiased: true + ForEach(0.. Date: Thu, 1 Oct 2026 21:04:40 +0800 Subject: [PATCH 03/16] Wire bubble-edge translation progress state --- mreader/ReaderView.swift | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 86eaea51..baac4572 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -1321,6 +1321,8 @@ struct ReaderView: View { @AppStorage("translation_target_language") private var translationTargetLanguage = TranslationTargetLanguage.simplifiedChinese.rawValue @AppStorage("translation_style_instructions") private var translationStyleInstructions = AITranslator.defaultTranslationStyleInstructions @AppStorage("vision_translation_prompt_template") private var visionTranslationPromptTemplate = AITranslator.defaultVisionTranslationPromptTemplate + @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true + @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.bubblePulse.rawValue @AppStorage("ocr_show_debug_boxes") private var ocrShowDebugBoxes = false @AppStorage("ocr_visual_verification_enabled") private var ocrVisualVerificationEnabled = false @AppStorage("ocr_local_recognition_mode") private var ocrRecognitionModeRaw = OCRRecognitionMode.adaptive.rawValue @@ -5776,6 +5778,8 @@ struct LocalImageView: View { @State private var isRecognizingOCR = false @State private var translationErrorMessage: String? @State private var translationTask: Task? + @State private var translationProgressGeometryTask: Task? + @State private var translationProgressRegions: [MangaVisionRegion] = [] @State private var offlineTranslationTask: Task? @State private var isOfflineTranslationDisplayed = false @State private var translationGeneration = UUID() @@ -5828,6 +5832,7 @@ struct LocalImageView: View { GeometryReader { geo in ZStack { translationOverlay(in: geo.size) + translationProgressOverlay(in: geo.size) ocrMagnificationOverlay(in: geo.size) ocrDebugOverlay(in: geo.size) #if DEBUG @@ -6046,8 +6051,37 @@ struct LocalImageView: View { .onChange(of: reportsTranslationActivity) { _, isActivePage in if !isActivePage { cancelLiveTranslationForPage() + } else if isTranslating, let image = uiImage { + startTranslationProgressGeometryPreparation( + image: image, + pageURL: url, + generation: translationGeneration + ) } } + .onChange(of: aiTranslationProgressEffectRaw) { _, _ in + guard isTranslating, let image = uiImage else { return } + startTranslationProgressGeometryPreparation( + image: image, + pageURL: url, + generation: translationGeneration + ) + } + .onChange(of: aiTranslationBorderProgressEnabled) { _, _ in + guard isTranslating, let image = uiImage else { + if !aiTranslationBorderProgressEnabled { + translationProgressGeometryTask?.cancel() + translationProgressGeometryTask = nil + translationProgressRegions.removeAll() + } + return + } + startTranslationProgressGeometryPreparation( + image: image, + pageURL: url, + generation: translationGeneration + ) + } .onChange(of: shouldDisplayOfflineTranslation) { _, newValue in offlineTranslationTask?.cancel() if isOfflineTranslationDisplayed { @@ -7271,6 +7305,9 @@ struct LocalImageView: View { private func cancelLiveTranslationForPage() { translationTask?.cancel() translationTask = nil + translationProgressGeometryTask?.cancel() + translationProgressGeometryTask = nil + translationProgressRegions.removeAll() translationGeneration = UUID() appleTranslationGeneration = UUID() @@ -7310,6 +7347,11 @@ struct LocalImageView: View { isTranslating = true } beginTranslationActivityIfNeeded() + startTranslationProgressGeometryPreparation( + image: image, + pageURL: pageURL, + generation: generation + ) translationTask = Task { do { @@ -7354,6 +7396,9 @@ struct LocalImageView: View { if self.isTranslating { self.isTranslating = false } + self.translationProgressGeometryTask?.cancel() + self.translationProgressGeometryTask = nil + self.translationProgressRegions.removeAll() self.endTranslationActivityIfNeeded() self.translationTask = nil } From d3da5afe019053db8f73a362920bc94ce405cca7 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:05:23 +0800 Subject: [PATCH 04/16] Render subtle breathing outlines while translating --- mreader/ReaderView.swift | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index baac4572..e4768899 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -6249,6 +6249,47 @@ struct LocalImageView: View { } } + @ViewBuilder + private func translationProgressOverlay(in size: CGSize) -> some View { + if isTranslating, + reportsTranslationActivity, + aiTranslationBorderProgressEnabled, + TranslationProgressEffect(rawValue: aiTranslationProgressEffectRaw) == .bubblePulse, + !translationProgressRegions.isEmpty { + let transform = ocrDisplayTransform(in: size) + ZStack { + ForEach(translationProgressRegions) { region in + let surfaceRect = OCRCoordinateMapper.displayRect( + forNormalizedPageRect: region.normalizedRect, + using: transform + ) + let mappedContour = (region.contour?.cgPoints ?? []).map { + OCRCoordinateMapper.displayPoint( + forNormalizedPagePoint: $0, + using: transform + ) + } + .filter { $0.x.isFinite && $0.y.isFinite } + .map { + CGPoint( + x: $0.x - surfaceRect.minX, + y: $0.y - surfaceRect.minY + ) + } + + TranslationProgressPulseStroke( + layoutSize: surfaceRect.size, + contour: mappedContour + ) + .position(x: surfaceRect.midX, y: surfaceRect.midY) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.opacity) + } + } + private var visibleTranslationBlocks: [TextBlock] { let candidates = textBlocks.filter { ($0.translation ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false @@ -7285,6 +7326,85 @@ struct LocalImageView: View { } } + private func startTranslationProgressGeometryPreparation( + image: UIImage, + pageURL: URL, + generation: UUID + ) { + translationProgressGeometryTask?.cancel() + translationProgressGeometryTask = nil + translationProgressRegions.removeAll() + + guard reportsTranslationActivity, + aiTranslationBorderProgressEnabled, + TranslationProgressEffect(rawValue: aiTranslationProgressEffectRaw) == .bubblePulse else { + return + } + + translationProgressGeometryTask = Task { + let analysisImage = await OCRPreprocessor.highResolutionImage( + from: pageURL, + fallback: image + ) ?? image + guard !Task.isCancelled else { return } + + let analysis = try? await MangaVisionService.shared.analysis( + comicID: comicID, + pageIndex: pageIndex, + pageURL: pageURL, + image: analysisImage + ) + guard !Task.isCancelled else { return } + + let regions: [MangaVisionRegion] + if let analysis { + let balloons = MangaVisionRegionPostProcessor.deduplicated( + analysis.balloons.filter { region in + let area = MangaPageCoordinateSpace.area(region.normalizedRect) + return region.confidence >= 0.30 + && area > 0 + && area <= 0.20 + }, + iouThreshold: 0.58, + containmentThreshold: 0.90 + ) + if !balloons.isEmpty { + regions = Array(balloons.prefix(24)) + } else { + // A text-region outline is only a fallback when no physical + // balloon is available. It remains a stroke-only indicator + // and never paints over the source artwork. + let textRegions = MangaVisionRegionPostProcessor.deduplicated( + analysis.texts.filter { region in + let area = MangaPageCoordinateSpace.area(region.normalizedRect) + return region.confidence >= 0.35 + && area > 0 + && area <= 0.16 + }, + iouThreshold: 0.58, + containmentThreshold: 0.88 + ) + regions = Array(textRegions.prefix(24)) + } + } else { + regions = [] + } + + await MainActor.run { + guard self.translationGeneration == generation, + self.url == pageURL, + self.isTranslating, + self.reportsTranslationActivity, + self.aiTranslationBorderProgressEnabled, + TranslationProgressEffect(rawValue: self.aiTranslationProgressEffectRaw) == .bubblePulse else { + return + } + self.translationProgressRegions = regions + self.translationProgressGeometryTask = nil + } + } + } + private func beginTranslationActivityIfNeeded() { guard reportsTranslationActivity, !didReportTranslationActivity else { return } didReportTranslationActivity = true From e6efa697ab2e9bc13e950f137f3cffc4eaafd480 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:12 +0800 Subject: [PATCH 05/16] Add low-frequency bubble edge pulse effect --- mreader/ReaderView.swift | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index e4768899..99d71185 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -7983,6 +7983,56 @@ nonisolated private struct TranslationBubbleContourShape: Shape { } } +private struct TranslationProgressPulseStroke: View { + let layoutSize: CGSize + let contour: [CGPoint] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + if reduceMotion { + stroke(opacity: 0.24, lineWidth: 1.1) + } else { + TimelineView(.periodic(from: .now, by: 1.0 / 12.0)) { context in + let duration: TimeInterval = 1.9 + let phase = context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: duration) / duration + let pulse = phase <= 0.5 ? phase * 2 : (1 - phase) * 2 + stroke( + opacity: 0.16 + pulse * 0.18, + lineWidth: 1.0 + CGFloat(pulse) * 0.25 + ) + } + } + } + .frame(width: layoutSize.width, height: layoutSize.height) + } + + @ViewBuilder + private func stroke(opacity: Double, lineWidth: CGFloat) -> some View { + if contour.count >= 3 { + TranslationBubbleContourShape(points: contour) + .stroke( + Color.secondary.opacity(opacity), + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round, + lineJoin: .round + ) + ) + } else { + RoundedRectangle( + cornerRadius: max(min(layoutSize.width, layoutSize.height) * 0.12, 4), + style: .continuous + ) + .stroke( + Color.secondary.opacity(opacity), + lineWidth: lineWidth + ) + } + } +} + private struct TranslationSurfaceRenderer: View { let layoutSize: CGSize let contour: [CGPoint] From 66580af7111b717a6365bd72329918db859c2131 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:43 +0800 Subject: [PATCH 06/16] Localize translation progress effect picker --- mreader/Base.lproj/Localizable.strings | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mreader/Base.lproj/Localizable.strings b/mreader/Base.lproj/Localizable.strings index d551db57..01fbec60 100644 --- a/mreader/Base.lproj/Localizable.strings +++ b/mreader/Base.lproj/Localizable.strings @@ -111,7 +111,10 @@ "ocr.appleTranslation" = "Apple 原生翻译"; "error.libraryLoad" = "Library Data Issue"; "ocr.appleTranslationFooter" = "Use Apple on-device low-latency translation for ordinary dialogue. Falls back to cloud AI when unavailable or incomplete."; -"ocr.borderProgress" = "AI Translation Border Progress"; +"ocr.borderProgress" = "Show Translation Progress Effect"; +"ocr.translationProgressEffect" = "Progress Effect"; +"ocr.translationProgressEffect.bubblePulse" = "Breathing Bubble Outline"; +"ocr.translationProgressEffect.marquee" = "Classic Marquee Border"; "ocr.colorStyle" = "Translation Text Color"; "ocr.targetLanguage" = "Translate To"; "translation.language.zh-Hans" = "Simplified Chinese"; From f62256b4264a499c0d6a5538324237fcc2004987 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:46 +0800 Subject: [PATCH 07/16] Localize translation progress effect picker --- mreader/en.lproj/Localizable.strings | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mreader/en.lproj/Localizable.strings b/mreader/en.lproj/Localizable.strings index 439ebc0c..f646e59c 100644 --- a/mreader/en.lproj/Localizable.strings +++ b/mreader/en.lproj/Localizable.strings @@ -115,7 +115,10 @@ "ocr.appleTranslation" = "Apple 原生翻译"; "error.libraryLoad" = "Library Data Issue"; "ocr.appleTranslationFooter" = "Use Apple on-device low-latency translation for ordinary dialogue. Falls back to cloud AI when unavailable or incomplete."; -"ocr.borderProgress" = "AI Translation Border Progress"; +"ocr.borderProgress" = "Show Translation Progress Effect"; +"ocr.translationProgressEffect" = "Progress Effect"; +"ocr.translationProgressEffect.bubblePulse" = "Breathing Bubble Outline"; +"ocr.translationProgressEffect.marquee" = "Classic Marquee Border"; "ocr.colorStyle" = "Translation Text Color"; "ocr.colorStyle.contrast" = "High Contrast"; "ocr.colorStyle.coolWarm" = "Cool & Warm"; From 1fd79bb704c99f4ca02d513c8241f9338c5d9ac6 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:50 +0800 Subject: [PATCH 08/16] Localize translation progress effect picker --- mreader/ja.lproj/Localizable.strings | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mreader/ja.lproj/Localizable.strings b/mreader/ja.lproj/Localizable.strings index 8e43f2cc..9e082f3b 100644 --- a/mreader/ja.lproj/Localizable.strings +++ b/mreader/ja.lproj/Localizable.strings @@ -112,7 +112,10 @@ "ocr.appleTranslation" = "Apple 原生翻译"; "error.libraryLoad" = "ライブラリデータの不具合"; "ocr.appleTranslationFooter" = "普通の会話は Apple の端末内・低遅延翻訳を使います。利用できない場合や未翻訳のまま残る場合はクラウド AI に自動フォールバックします。"; -"ocr.borderProgress" = "AI 翻訳ボーダー進捗"; +"ocr.borderProgress" = "翻訳中エフェクトを表示"; +"ocr.translationProgressEffect" = "進行エフェクト"; +"ocr.translationProgressEffect.bubblePulse" = "吹き出し呼吸アウトライン"; +"ocr.translationProgressEffect.marquee" = "クラシック走査ボーダー"; "ocr.colorStyle" = "翻訳テキスト配色"; "ocr.colorStyle.contrast" = "ハイコントラスト"; "ocr.colorStyle.coolWarm" = "クール・ウォーム"; From bdcc7b5311782e29ca6ae8ceb5932630a45bf15a Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:54 +0800 Subject: [PATCH 09/16] Localize translation progress effect picker --- mreader/ko.lproj/Localizable.strings | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mreader/ko.lproj/Localizable.strings b/mreader/ko.lproj/Localizable.strings index c4dcd7fc..13a45177 100644 --- a/mreader/ko.lproj/Localizable.strings +++ b/mreader/ko.lproj/Localizable.strings @@ -112,7 +112,10 @@ "ocr.appleTranslation" = "Apple 原生翻译"; "error.libraryLoad" = "라이브러리 데이터 문제"; "ocr.appleTranslationFooter" = "일반 대사는 Apple 기기 내 저지연 번역을 사용합니다. 사용할 수 없거나 미번역 항목이 남으면 클라우드 AI로 자동 폴백합니다."; -"ocr.borderProgress" = "AI 번역 테두리 진행률"; +"ocr.borderProgress" = "번역 진행 효과 표시"; +"ocr.translationProgressEffect" = "진행 효과"; +"ocr.translationProgressEffect.bubblePulse" = "말풍선 호흡 윤곽선"; +"ocr.translationProgressEffect.marquee" = "클래식 마키 테두리"; "ocr.colorStyle" = "번역 텍스트 색상"; "ocr.colorStyle.contrast" = "고대비"; "ocr.colorStyle.coolWarm" = "쿨·웜"; From df21c1e6721255751074f92a9a1af61dfb2d3ec4 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:06:58 +0800 Subject: [PATCH 10/16] Localize translation progress effect picker --- mreader/zh-Hans.lproj/Localizable.strings | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mreader/zh-Hans.lproj/Localizable.strings b/mreader/zh-Hans.lproj/Localizable.strings index a6e18320..ad02dcb9 100644 --- a/mreader/zh-Hans.lproj/Localizable.strings +++ b/mreader/zh-Hans.lproj/Localizable.strings @@ -115,7 +115,10 @@ "ocr.appleTranslation" = "Apple 原生翻译"; "error.libraryLoad" = "书架数据异常"; "ocr.appleTranslationFooter" = "普通对白使用 Apple 端侧低延迟翻译;不可用或未覆盖时自动回退云端 AI。"; -"ocr.borderProgress" = "AI 翻译边框进度"; +"ocr.borderProgress" = "显示翻译进度特效"; +"ocr.translationProgressEffect" = "翻译中特效"; +"ocr.translationProgressEffect.bubblePulse" = "气泡呼吸描边"; +"ocr.translationProgressEffect.marquee" = "经典跑马灯"; "ocr.colorStyle" = "翻译文字配色"; "ocr.colorStyle.contrast" = "高对比"; "ocr.colorStyle.coolWarm" = "冷暖分明"; From ea3f48c56c4233d30959d3ffcef2a8eff9104e71 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:07:32 +0800 Subject: [PATCH 11/16] Centralize translation progress region policy --- mreader/ReaderView.swift | 73 +++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 99d71185..09f024e4 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -137,6 +137,7 @@ nonisolated enum ImageFitMode: String, CaseIterable { nonisolated enum TranslationProgressEffect: String, CaseIterable, Sendable { static let defaultsKey = "ai_translation_progress_effect" + static let defaultEffect: Self = .bubblePulse case bubblePulse case marquee @@ -151,6 +152,38 @@ nonisolated enum TranslationProgressEffect: String, CaseIterable, Sendable { } } +nonisolated enum TranslationProgressRegionPolicy { + static let maximumRegionCount = 24 + + static func regions(from analysis: MangaPageAnalysis) -> [MangaVisionRegion] { + let balloons = MangaVisionRegionPostProcessor.deduplicated( + analysis.balloons.filter { region in + let area = MangaPageCoordinateSpace.area(region.normalizedRect) + return region.confidence >= 0.30 + && area > 0 + && area <= 0.20 + }, + iouThreshold: 0.58, + containmentThreshold: 0.90 + ) + if !balloons.isEmpty { + return Array(balloons.prefix(maximumRegionCount)) + } + + let textRegions = MangaVisionRegionPostProcessor.deduplicated( + analysis.texts.filter { region in + let area = MangaPageCoordinateSpace.area(region.normalizedRect) + return region.confidence >= 0.35 + && area > 0 + && area <= 0.16 + }, + iouThreshold: 0.58, + containmentThreshold: 0.88 + ) + return Array(textRegions.prefix(maximumRegionCount)) + } +} + /// 缩放 / 平移的纯几何计算,独立于视图以便直接测试。 /// /// 关键点:图片**不一定**铺满 viewport。`fitScreen` / `fitHeight` 会留下上下或左右留白, @@ -1322,12 +1355,12 @@ struct ReaderView: View { @AppStorage("translation_style_instructions") private var translationStyleInstructions = AITranslator.defaultTranslationStyleInstructions @AppStorage("vision_translation_prompt_template") private var visionTranslationPromptTemplate = AITranslator.defaultVisionTranslationPromptTemplate @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true - @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.bubblePulse.rawValue + @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.defaultEffect.rawValue @AppStorage("ocr_show_debug_boxes") private var ocrShowDebugBoxes = false @AppStorage("ocr_visual_verification_enabled") private var ocrVisualVerificationEnabled = false @AppStorage("ocr_local_recognition_mode") private var ocrRecognitionModeRaw = OCRRecognitionMode.adaptive.rawValue @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true - @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.bubblePulse.rawValue + @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.defaultEffect.rawValue @AppStorage("translation_color_style") private var translationColorStyleRaw = TranslationColorStyle.contrast.rawValue @AppStorage("translation_use_apple_low_latency") private var useAppleLowLatency = false @AppStorage(MangaVisionHardCaseFeature.shortcutDefaultsKey) private var showMangaVisionFeedbackShortcut = false @@ -7356,39 +7389,9 @@ struct LocalImageView: View { ) guard !Task.isCancelled else { return } - let regions: [MangaVisionRegion] - if let analysis { - let balloons = MangaVisionRegionPostProcessor.deduplicated( - analysis.balloons.filter { region in - let area = MangaPageCoordinateSpace.area(region.normalizedRect) - return region.confidence >= 0.30 - && area > 0 - && area <= 0.20 - }, - iouThreshold: 0.58, - containmentThreshold: 0.90 - ) - if !balloons.isEmpty { - regions = Array(balloons.prefix(24)) - } else { - // A text-region outline is only a fallback when no physical - // balloon is available. It remains a stroke-only indicator - // and never paints over the source artwork. - let textRegions = MangaVisionRegionPostProcessor.deduplicated( - analysis.texts.filter { region in - let area = MangaPageCoordinateSpace.area(region.normalizedRect) - return region.confidence >= 0.35 - && area > 0 - && area <= 0.16 - }, - iouThreshold: 0.58, - containmentThreshold: 0.88 - ) - regions = Array(textRegions.prefix(24)) - } - } else { - regions = [] - } + // A physical balloon is preferred. Text regions are a stroke-only + // fallback when the model cannot provide balloon geometry. + let regions = analysis.map(TranslationProgressRegionPolicy.regions(from:)) ?? [] await MainActor.run { guard self.translationGeneration == generation, From 6d0eca68650936a70d81f0ffee8dbccf6578e484 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:08:46 +0800 Subject: [PATCH 12/16] Fix translation progress effect state ownership --- mreader/ReaderView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 09f024e4..89721311 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -1359,8 +1359,6 @@ struct ReaderView: View { @AppStorage("ocr_show_debug_boxes") private var ocrShowDebugBoxes = false @AppStorage("ocr_visual_verification_enabled") private var ocrVisualVerificationEnabled = false @AppStorage("ocr_local_recognition_mode") private var ocrRecognitionModeRaw = OCRRecognitionMode.adaptive.rawValue - @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true - @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.defaultEffect.rawValue @AppStorage("translation_color_style") private var translationColorStyleRaw = TranslationColorStyle.contrast.rawValue @AppStorage("translation_use_apple_low_latency") private var useAppleLowLatency = false @AppStorage(MangaVisionHardCaseFeature.shortcutDefaultsKey) private var showMangaVisionFeedbackShortcut = false @@ -5825,6 +5823,8 @@ struct LocalImageView: View { @State private var appleSourceLanguageCode: String? = nil @AppStorage("translation_style_instructions") private var translationStyleInstructions = AITranslator.defaultTranslationStyleInstructions @AppStorage("vision_translation_prompt_template") private var visionTranslationPromptTemplate = AITranslator.defaultVisionTranslationPromptTemplate + @AppStorage("ai_translation_border_progress_enabled") private var aiTranslationBorderProgressEnabled = true + @AppStorage(TranslationProgressEffect.defaultsKey) private var aiTranslationProgressEffectRaw = TranslationProgressEffect.defaultEffect.rawValue @AppStorage("ocr_show_debug_boxes") private var ocrShowDebugBoxes = false @AppStorage("ocr_debug_stage") private var ocrDebugStageRaw = OCRDebugStage.raw.rawValue @AppStorage("ocr_visual_verification_enabled") private var ocrVisualVerificationEnabled = false @@ -7391,7 +7391,9 @@ struct LocalImageView: View { // A physical balloon is preferred. Text regions are a stroke-only // fallback when the model cannot provide balloon geometry. - let regions = analysis.map(TranslationProgressRegionPolicy.regions(from:)) ?? [] + let regions = analysis.map { + TranslationProgressRegionPolicy.regions(from: $0) + } ?? [] await MainActor.run { guard self.translationGeneration == generation, From 2bcabf1e622fd74c2d3f91c5aa411423d6f4e389 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:09:12 +0800 Subject: [PATCH 13/16] Cover translation progress effect region policy --- mreaderTests/MangaVisionLayerTests.swift | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/mreaderTests/MangaVisionLayerTests.swift b/mreaderTests/MangaVisionLayerTests.swift index 179b0256..6d20a696 100644 --- a/mreaderTests/MangaVisionLayerTests.swift +++ b/mreaderTests/MangaVisionLayerTests.swift @@ -130,6 +130,91 @@ struct MangaVisionLayerTests { #expect(segmentation.bubbles[0].bubbleBox == balloon.normalizedRect) } + @Test func translationProgressDefaultsToSubtleBubblePulse() { + #expect(TranslationProgressEffect.defaultEffect == .bubblePulse) + #expect(TranslationProgressEffect.allCases.map(\.rawValue) == ["bubblePulse", "marquee"]) + } + + @Test func translationProgressPrefersPhysicalBalloonsOverTextFallback() { + let balloon = region( + .balloon, + x: 0.20, + y: 0.20, + width: 0.34, + height: 0.18, + confidence: 0.92 + ) + let text = region( + .text, + x: 0.26, + y: 0.25, + width: 0.16, + height: 0.06, + confidence: 0.94 + ) + let analysis = MangaPageAnalysis( + pageIdentifier: MangaPageIdentifier( + scope: "translation-progress-balloon", + pageIndex: 0, + sourceFingerprint: "fixture" + ), + imageSize: CGSize(width: 1200, height: 1800), + panels: [], + texts: [text], + balloons: [balloon], + faces: [], + bodies: [], + modelIdentifier: "fixture", + modelVersion: 4 + ) + + let regions = TranslationProgressRegionPolicy.regions(from: analysis) + + #expect(regions.count == 1) + #expect(regions[0].id == balloon.id) + #expect(regions[0].type == .balloon) + } + + @Test func translationProgressRejectsPanelSizedBalloonAndFallsBackToText() { + let oversizedBalloon = region( + .balloon, + x: 0.08, + y: 0.10, + width: 0.76, + height: 0.42, + confidence: 0.98 + ) + let text = region( + .text, + x: 0.30, + y: 0.24, + width: 0.20, + height: 0.07, + confidence: 0.90 + ) + let analysis = MangaPageAnalysis( + pageIdentifier: MangaPageIdentifier( + scope: "translation-progress-text-fallback", + pageIndex: 0, + sourceFingerprint: "fixture" + ), + imageSize: CGSize(width: 1200, height: 1800), + panels: [], + texts: [text], + balloons: [oversizedBalloon], + faces: [], + bodies: [], + modelIdentifier: "fixture", + modelVersion: 4 + ) + + let regions = TranslationProgressRegionPolicy.regions(from: analysis) + + #expect(regions.count == 1) + #expect(regions[0].id == text.id) + #expect(regions[0].type == .text) + } + @Test func mangaBalloonContourFlowsIntoTranslationUnitAndSafeRegion() { let contourPoints = [ CGPoint(x: 0.50, y: 0.18), From a6b7616ba8ce7a4ece5f579cd8aa53c1ae08aacd Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:12:20 +0800 Subject: [PATCH 14/16] Avoid extra image decode for progress outlines --- mreader/ReaderView.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 89721311..3158072d 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -7375,17 +7375,15 @@ struct LocalImageView: View { } translationProgressGeometryTask = Task { - let analysisImage = await OCRPreprocessor.highResolutionImage( - from: pageURL, - fallback: image - ) ?? image + // The indicator is decorative geometry, not OCR input. Reuse the + // already-decoded reader image instead of triggering a second + // high-resolution decode just to draw a thin progress outline. guard !Task.isCancelled else { return } - let analysis = try? await MangaVisionService.shared.analysis( comicID: comicID, pageIndex: pageIndex, pageURL: pageURL, - image: analysisImage + image: image ) guard !Task.isCancelled else { return } From 1a7da972ca4af40820e32abdd9f7bce0726ba364 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:58:29 +0800 Subject: [PATCH 15/16] Split reader lifecycle expressions for Swift type checker --- mreader/ReaderView.swift | 106 ++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 3158072d..55bd6245 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -6017,30 +6017,7 @@ struct LocalImageView: View { } } .task(id: imageLoadTaskID) { - guard retainsDecodedImage else { - cancelLiveTranslationForPage() - offlineTranslationTask?.cancel() - offlineTranslationTask = nil - ocrMagnificationTask?.cancel() - ocrMagnificationTask = nil - uiImage = nil - loadedPageURL = nil - isLoadingImage = false - return - } - // 视图身份可能被复用:分镜跨页要靠同一个视图身份才能让相机在 - // scaleEffect/offset 上插值(否则整页就是硬切)。所以不能只看 - // uiImage 是否为空,必须比对已经加载的是不是当前这一页。 - guard loadedPageURL != url || uiImage == nil else { return } - if imageLoadDelay > 0 { - do { - try await Task.sleep(for: .seconds(imageLoadDelay)) - } catch { - return - } - } - guard !Task.isCancelled else { return } - await loadImage() + await performImageLoadTask() } .onDisappear { cancelLiveTranslationForPage() @@ -6082,38 +6059,13 @@ struct LocalImageView: View { } } .onChange(of: reportsTranslationActivity) { _, isActivePage in - if !isActivePage { - cancelLiveTranslationForPage() - } else if isTranslating, let image = uiImage { - startTranslationProgressGeometryPreparation( - image: image, - pageURL: url, - generation: translationGeneration - ) - } + handleTranslationActivityEligibilityChange(isActivePage) } .onChange(of: aiTranslationProgressEffectRaw) { _, _ in - guard isTranslating, let image = uiImage else { return } - startTranslationProgressGeometryPreparation( - image: image, - pageURL: url, - generation: translationGeneration - ) + refreshTranslationProgressEffectIfNeeded() } .onChange(of: aiTranslationBorderProgressEnabled) { _, _ in - guard isTranslating, let image = uiImage else { - if !aiTranslationBorderProgressEnabled { - translationProgressGeometryTask?.cancel() - translationProgressGeometryTask = nil - translationProgressRegions.removeAll() - } - return - } - startTranslationProgressGeometryPreparation( - image: image, - pageURL: url, - generation: translationGeneration - ) + refreshTranslationProgressEffectIfNeeded() } .onChange(of: shouldDisplayOfflineTranslation) { _, newValue in offlineTranslationTask?.cancel() @@ -6194,6 +6146,56 @@ struct LocalImageView: View { } } + private func performImageLoadTask() async { + guard retainsDecodedImage else { + cancelLiveTranslationForPage() + offlineTranslationTask?.cancel() + offlineTranslationTask = nil + ocrMagnificationTask?.cancel() + ocrMagnificationTask = nil + uiImage = nil + loadedPageURL = nil + isLoadingImage = false + return + } + + // The same LocalImageView identity may be reused across guided-panel page + // transitions, so verify the loaded URL rather than only checking uiImage. + guard loadedPageURL != url || uiImage == nil else { return } + if imageLoadDelay > 0 { + do { + try await Task.sleep(for: .seconds(imageLoadDelay)) + } catch { + return + } + } + guard !Task.isCancelled else { return } + await loadImage() + } + + private func handleTranslationActivityEligibilityChange(_ isActivePage: Bool) { + if !isActivePage { + cancelLiveTranslationForPage() + return + } + refreshTranslationProgressEffectIfNeeded() + } + + private func refreshTranslationProgressEffectIfNeeded() { + guard aiTranslationBorderProgressEnabled else { + translationProgressGeometryTask?.cancel() + translationProgressGeometryTask = nil + translationProgressRegions.removeAll() + return + } + guard isTranslating, let image = uiImage else { return } + startTranslationProgressGeometryPreparation( + image: image, + pageURL: url, + generation: translationGeneration + ) + } + @ViewBuilder private func fittedImage(_ image: UIImage) -> some View { switch imageFitMode { From 1f2cc480d9e36ebcd8a721255fd29eeab3f54ebe Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:26:12 +0800 Subject: [PATCH 16/16] Decompose LocalImageView to unblock Swift type checking --- mreader/ReaderView.swift | 569 +++++++++++++++++++++++---------------- 1 file changed, 336 insertions(+), 233 deletions(-) diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index 55bd6245..6d4296d5 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -5856,33 +5856,26 @@ struct LocalImageView: View { } var body: some View { + secondaryLifecycleContent + } + + private var renderedPageContent: some View { ZStack(alignment: .bottomTrailing) { - if let uiImage = uiImage { - // 1. 底图与翻译文本覆盖层 + if let uiImage { fittedImage(uiImage) - // 核心逻辑:直接在图片上层按比例渲染文本气泡 - .overlay( + .overlay { GeometryReader { geo in - ZStack { - translationOverlay(in: geo.size) - translationProgressOverlay(in: geo.size) - ocrMagnificationOverlay(in: geo.size) - ocrDebugOverlay(in: geo.size) -#if DEBUG - mangaVisionDebugOverlay(in: geo.size) -#endif - } - .onAppear { zoomContentSize = geo.size } - .onChange(of: geo.size) { _, newValue in zoomContentSize = newValue } + imageOverlayContent(in: geo.size) + .onAppear { zoomContentSize = geo.size } + .onChange(of: geo.size) { _, newValue in + zoomContentSize = newValue + } } - ) + } .scaleEffect(scale) .offset(offset) - // 放大后把图片裁剪在自身布局框内,避免溢出到相邻页面与翻页过渡叠加。 .clipped() .gesture(zoomGesture) - // 只有分页阅读器才安装高优先级单指 pan。连续滚动完全不安装该 recognizer, - // 让 UIScrollView 的 panGesture 独占单指拖动。 .modifier( ReaderOptionalPanGestureModifier( isEnabled: isSingleFingerPanEnabled, @@ -5892,260 +5885,370 @@ struct LocalImageView: View { .simultaneousGesture(tapPageGesture) .simultaneousGesture(longPressTranslationGesture) .frame(height: displayHeight(for: uiImage)) - } else if isLoadingImage { if showsLoadingIndicator { - ProgressView().tint(.white).controlSize(.regular) + ProgressView() + .tint(.white) + .controlSize(.regular) } else { Color.clear } } else if loadFailed { - ContentUnavailableView("reader.imageLoadFailed".localized, systemImage: "exclamationmark.triangle", description: Text(url.lastPathComponent)) - .foregroundStyle(.white) + ContentUnavailableView( + "reader.imageLoadFailed".localized, + systemImage: "exclamationmark.triangle", + description: Text(url.lastPathComponent) + ) + .foregroundStyle(.white) } } .overlay(alignment: .bottom) { - if let translationErrorMessage { - Text(translationErrorMessage) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.black.opacity(0.62), in: Capsule()) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } + translationErrorOverlay } .background { - if useAppleLowLatency, aiTranslationMode == .ocr, !appleTranslationRequests.isEmpty, - let sourceCode = appleSourceLanguageCode { - let bridgeGeneration = appleTranslationGeneration - let bridgeTarget = TranslationTargetLanguage.migrateLegacyValue(targetLanguage).rawValue - AppleTranslationBridge( - sourceLanguage: Locale.Language(identifier: sourceCode), - targetLanguage: Locale.Language(identifier: bridgeTarget), - requests: appleTranslationRequests, - onResult: { id, text in - guard self.isAppleBridgeCurrent( - generation: bridgeGeneration, - pageURL: self.url, - targetLanguage: bridgeTarget - ) else { return } - guard let index = self.textBlocks.firstIndex(where: { $0.id == id }) else { return } - let target = TranslationTargetLanguage.migrateLegacyValue(bridgeTarget) - guard let normalized = TranslationOutputValidator.normalizedAcceptableTranslation( - text, - sourceText: self.textBlocks[index].text, - target: target - ) else { - // Apple can occasionally echo the English source unchanged. - // Leave the block empty so onFinished sends it through the - // existing cloud text fallback instead of rendering a leak. - return - } - self.textBlocks[index].translation = normalized - self.textBlocks[index].translationLines = [normalized] - }, - onFinished: { seen in - guard self.isAppleBridgeCurrent( - generation: bridgeGeneration, - pageURL: self.url, - targetLanguage: bridgeTarget - ) else { return } - // 把整页(含增量结果)写入 Apple 页缓存,翻回旧页不再重复翻译 - let cacheKey = AppleTranslationPageCache.key( - pageURL: self.url, - sourceLanguage: sourceCode, - targetLanguage: bridgeTarget, - segmentationRevision: AITranslationPageRequest.ocrGeometryRevision, - ocrRecognitionMode: OCRRecognitionMode(rawValue: self.ocrRecognitionModeRaw) ?? .adaptive, - usesVisualOCRVerification: self.ocrVisualVerificationEnabled, - isRightToLeft: self.isRightToLeftReading, - minimumTextHeight: self.ocrMinimumTextHeight, - safeAreaInset: self.ocrSafeAreaInset - ) - Task { - await AppleTranslationPageCache.shared.store(self.textBlocks, key: cacheKey) - let target = TranslationTargetLanguage.migrateLegacyValue(bridgeTarget) - await TranslationContextRegistry.shared.record( - scopeID: TranslationContextBuilder.scopeID( - comicID: self.comicID, - target: target - ), - pageIndex: self.pageIndex, - blocks: self.textBlocks - ) + appleTranslationBridgeBackground + } + } + + @ViewBuilder + private func imageOverlayContent(in size: CGSize) -> some View { + ZStack { + translationOverlay(in: size) + translationProgressOverlay(in: size) + ocrMagnificationOverlay(in: size) + ocrDebugOverlay(in: size) +#if DEBUG + mangaVisionDebugOverlay(in: size) +#endif + } + } + + @ViewBuilder + private var translationErrorOverlay: some View { + if let translationErrorMessage { + Text(translationErrorMessage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(0.62), in: Capsule()) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + + @ViewBuilder + private var appleTranslationBridgeBackground: some View { + if useAppleLowLatency, + aiTranslationMode == .ocr, + !appleTranslationRequests.isEmpty, + let sourceCode = appleSourceLanguageCode { + let bridgeGeneration = appleTranslationGeneration + let bridgeTarget = TranslationTargetLanguage.migrateLegacyValue(targetLanguage).rawValue + AppleTranslationBridge( + sourceLanguage: Locale.Language(identifier: sourceCode), + targetLanguage: Locale.Language(identifier: bridgeTarget), + requests: appleTranslationRequests, + onResult: { id, text in + handleAppleTranslationResult( + id: id, + text: text, + generation: bridgeGeneration, + targetLanguage: bridgeTarget + ) + }, + onFinished: { seen in + handleAppleTranslationFinished( + seen: seen, + sourceCode: sourceCode, + generation: bridgeGeneration, + targetLanguage: bridgeTarget + ) + } + ) + .id(appleTranslationGeneration) + } + } + + private var sizedPageContent: some View { + renderedPageContent + .frame(height: reservedDisplayHeight) + .frame( + maxWidth: .infinity, + minHeight: imageFitMode == .fitWidth ? nil : 0, + maxHeight: imageFitMode == .fitWidth ? nil : .infinity + ) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + viewportWidth = proxy.size.width + viewportSize = proxy.size } - let missing = self.appleTranslationRequests - .filter { request in - guard seen.contains(request.id), - let block = self.textBlocks.first(where: { $0.id == request.id }) else { - return true - } - return (block.translation ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - } - .map(\.id) - if !missing.isEmpty { - self.cloudFallbackForMissing( - missing, - generation: bridgeGeneration, - pageURL: self.url, - targetLanguage: bridgeTarget - ) + .onChange(of: proxy.size) { _, newValue in + viewportWidth = newValue.width + viewportSize = newValue } - } - ) - .id(appleTranslationGeneration) + } } - } - .frame(height: reservedDisplayHeight) - .frame(maxWidth: .infinity, minHeight: imageFitMode == .fitWidth ? nil : 0, maxHeight: imageFitMode == .fitWidth ? nil : .infinity) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - viewportWidth = proxy.size.width - viewportSize = proxy.size - } - .onChange(of: proxy.size) { _, newValue in - viewportWidth = newValue.width - viewportSize = newValue - } + } + + private var primaryLifecycleContent: some View { + sizedPageContent + .task(id: imageLoadTaskID) { + await performImageLoadTask() } - } - .task(id: imageLoadTaskID) { - await performImageLoadTask() - } - .onDisappear { - cancelLiveTranslationForPage() - offlineTranslationTask?.cancel() - offlineTranslationTask = nil - ocrMagnificationTask?.cancel() - ocrMagnificationTask = nil - if isZoomedIn { - onZoomChange(false) + .onDisappear { + handleLocalImageDisappear() } - } - .onChange(of: isZoomedIn) { _, zoomed in - if !zoomed { - // 回到 1x:清空平移残留,避免下次放大沿用旧位移。 - offset = .zero - lastOffset = .zero - lastScale = 1 + .onChange(of: isZoomedIn) { _, zoomed in + handleZoomStateChange(zoomed) } - onZoomChange(zoomed) - } - .onChange(of: translateRequestID) { _, _ in - startTranslation(force: true) - } - .onChange(of: ocrMagnifyRequestID) { _, _ in - startOCRMagnification() - } - .onChange(of: isOCRMagnificationVisible) { _, newValue in - if newValue { + .onChange(of: translateRequestID) { _, _ in + startTranslation(force: true) + } + .onChange(of: ocrMagnifyRequestID) { _, _ in startOCRMagnification() - } else { - ocrTextBlocks.removeAll() } - } - .onChange(of: isAutoTranslationEnabled) { _, newValue in - if newValue { - startTranslation() - } else { - cancelLiveTranslationForPage() + .onChange(of: isOCRMagnificationVisible) { _, newValue in + handleOCRMagnificationVisibilityChange(newValue) } - } - .onChange(of: reportsTranslationActivity) { _, isActivePage in - handleTranslationActivityEligibilityChange(isActivePage) - } - .onChange(of: aiTranslationProgressEffectRaw) { _, _ in - refreshTranslationProgressEffectIfNeeded() - } - .onChange(of: aiTranslationBorderProgressEnabled) { _, _ in - refreshTranslationProgressEffectIfNeeded() - } - .onChange(of: shouldDisplayOfflineTranslation) { _, newValue in - offlineTranslationTask?.cancel() - if isOfflineTranslationDisplayed { - isOfflineTranslationDisplayed = false - textBlocks.removeAll() + .onChange(of: isAutoTranslationEnabled) { _, newValue in + handleAutoTranslationChange(newValue) } - guard newValue else { - return + .onChange(of: reportsTranslationActivity) { _, isActivePage in + handleTranslationActivityEligibilityChange(isActivePage) } - Task { - let loaded = await loadOfflineTranslationIfAvailable() - if !loaded, isAutoTranslationEnabled { - startTranslation() - } + } + + private var secondaryLifecycleContent: some View { + primaryLifecycleContent + .onChange(of: aiTranslationProgressEffectRaw) { _, _ in + refreshTranslationProgressEffectIfNeeded() } - } - .onChange(of: isOCREnabled) { _, newValue in - if !newValue { - ocrTextBlocks.removeAll() + .onChange(of: aiTranslationBorderProgressEnabled) { _, _ in + refreshTranslationProgressEffectIfNeeded() } - } - .onChange(of: canTranslate) { _, newValue in - if !newValue, !isOfflineTranslationDisplayed { - textBlocks.removeAll() + .onChange(of: shouldDisplayOfflineTranslation) { _, newValue in + handleOfflineTranslationVisibilityChange(newValue) } - } - .onChange(of: targetLanguage) { _, _ in - textBlocks.removeAll() - isOfflineTranslationDisplayed = false - if isAutoTranslationEnabled { - Task { - let loaded = await loadOfflineTranslationIfAvailable() - if !loaded { startTranslation() } + .onChange(of: isOCREnabled) { _, newValue in + if !newValue { + ocrTextBlocks.removeAll() } } - } - .onChange(of: translationSourceLanguageRaw) { _, _ in - // 修改原文语言后,当前翻译与 Apple 请求一并失效并重译(审查 #4); - // 同时清掉之前自动识别的 stable language,避免旧语言继续污染(审查 #9) - textBlocks.removeAll() - isOfflineTranslationDisplayed = false - appleTranslationRequests.removeAll() - clearStableSourceLanguage() - if isAutoTranslationEnabled { - Task { - let loaded = await loadOfflineTranslationIfAvailable() - if !loaded { startTranslation() } + .onChange(of: canTranslate) { _, newValue in + if !newValue, !isOfflineTranslationDisplayed { + textBlocks.removeAll() } } + .onChange(of: targetLanguage) { _, _ in + handleTargetLanguageChange() + } + .onChange(of: translationSourceLanguageRaw) { _, _ in + handleSourceLanguageChange() + } + .onChange(of: aiTranslationModeRaw) { _, _ in + handleTranslationModeChange() + } + .onReceive(NotificationCenter.default.publisher(for: .offlineTranslationPageDidUpdate)) { notification in + handleOfflineTranslationPageUpdate(notification) + } + .onChange(of: ocrRecognitionModeRaw) { _, _ in + handleOCRRecognitionModeChange() + } + } + + private func handleAppleTranslationResult( + id: UUID, + text: String, + generation: UUID, + targetLanguage: String + ) { + guard isAppleBridgeCurrent( + generation: generation, + pageURL: url, + targetLanguage: targetLanguage + ) else { return } + guard let index = textBlocks.firstIndex(where: { $0.id == id }) else { return } + let target = TranslationTargetLanguage.migrateLegacyValue(targetLanguage) + guard let normalized = TranslationOutputValidator.normalizedAcceptableTranslation( + text, + sourceText: textBlocks[index].text, + target: target + ) else { + return } - .onChange(of: aiTranslationModeRaw) { _, _ in + textBlocks[index].translation = normalized + textBlocks[index].translationLines = [normalized] + } + + private func handleAppleTranslationFinished( + seen: Set, + sourceCode: String, + generation: UUID, + targetLanguage: String + ) { + guard isAppleBridgeCurrent( + generation: generation, + pageURL: url, + targetLanguage: targetLanguage + ) else { return } + + let cacheKey = AppleTranslationPageCache.key( + pageURL: url, + sourceLanguage: sourceCode, + targetLanguage: targetLanguage, + segmentationRevision: AITranslationPageRequest.ocrGeometryRevision, + ocrRecognitionMode: OCRRecognitionMode(rawValue: ocrRecognitionModeRaw) ?? .adaptive, + usesVisualOCRVerification: ocrVisualVerificationEnabled, + isRightToLeft: isRightToLeftReading, + minimumTextHeight: ocrMinimumTextHeight, + safeAreaInset: ocrSafeAreaInset + ) + let blocksSnapshot = textBlocks + let target = TranslationTargetLanguage.migrateLegacyValue(targetLanguage) + Task { + await AppleTranslationPageCache.shared.store(blocksSnapshot, key: cacheKey) + await TranslationContextRegistry.shared.record( + scopeID: TranslationContextBuilder.scopeID( + comicID: comicID, + target: target + ), + pageIndex: pageIndex, + blocks: blocksSnapshot + ) + } + + let missing = appleTranslationRequests + .filter { request in + guard seen.contains(request.id), + let block = textBlocks.first(where: { $0.id == request.id }) else { + return true + } + return (block.translation ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + .map(\.id) + if !missing.isEmpty { + cloudFallbackForMissing( + missing, + generation: generation, + pageURL: url, + targetLanguage: targetLanguage + ) + } + } + + private func handleLocalImageDisappear() { + cancelLiveTranslationForPage() + offlineTranslationTask?.cancel() + offlineTranslationTask = nil + ocrMagnificationTask?.cancel() + ocrMagnificationTask = nil + if isZoomedIn { + onZoomChange(false) + } + } + + private func handleZoomStateChange(_ zoomed: Bool) { + if !zoomed { + offset = .zero + lastOffset = .zero + lastScale = 1 + } + onZoomChange(zoomed) + } + + private func handleOCRMagnificationVisibilityChange(_ isVisible: Bool) { + if isVisible { + startOCRMagnification() + } else { + ocrTextBlocks.removeAll() + } + } + + private func handleAutoTranslationChange(_ enabled: Bool) { + if enabled { + startTranslation() + } else { + cancelLiveTranslationForPage() + } + } + + private func handleOfflineTranslationVisibilityChange(_ isVisible: Bool) { + offlineTranslationTask?.cancel() + if isOfflineTranslationDisplayed { + isOfflineTranslationDisplayed = false textBlocks.removeAll() - if isAutoTranslationEnabled { + } + guard isVisible else { return } + Task { + let loaded = await loadOfflineTranslationIfAvailable() + if !loaded, isAutoTranslationEnabled { startTranslation() } } - .onReceive(NotificationCenter.default.publisher(for: .offlineTranslationPageDidUpdate)) { notification in - guard shouldDisplayOfflineTranslation, - let comicID = notification.userInfo?[OfflineTranslationNotificationKey.comicID] as? UUID, - comicID == comic?.id, - let updatedPageIndex = notification.userInfo?[OfflineTranslationNotificationKey.pageIndex] as? Int, - updatedPageIndex == pageIndex else { - return - } - Task { - _ = await loadOfflineTranslationIfAvailable() + } + + private func handleTargetLanguageChange() { + textBlocks.removeAll() + isOfflineTranslationDisplayed = false + guard isAutoTranslationEnabled else { return } + Task { + let loaded = await loadOfflineTranslationIfAvailable() + if !loaded { + startTranslation() } } - .onChange(of: ocrRecognitionModeRaw) { _, _ in - recognizedPipelineCacheKey = nil - recognizedPipelineCache = nil - ocrTextBlocks.removeAll() - textBlocks.removeAll() - if isOCRMagnificationVisible { - startOCRMagnification() + } + + private func handleSourceLanguageChange() { + textBlocks.removeAll() + isOfflineTranslationDisplayed = false + appleTranslationRequests.removeAll() + clearStableSourceLanguage() + guard isAutoTranslationEnabled else { return } + Task { + let loaded = await loadOfflineTranslationIfAvailable() + if !loaded { + startTranslation() } } } + private func handleTranslationModeChange() { + textBlocks.removeAll() + if isAutoTranslationEnabled { + startTranslation() + } + } + + private func handleOfflineTranslationPageUpdate(_ notification: Notification) { + guard shouldDisplayOfflineTranslation, + let comicID = notification.userInfo?[OfflineTranslationNotificationKey.comicID] as? UUID, + comicID == comic?.id, + let updatedPageIndex = notification.userInfo?[OfflineTranslationNotificationKey.pageIndex] as? Int, + updatedPageIndex == pageIndex else { + return + } + Task { + _ = await loadOfflineTranslationIfAvailable() + } + } + + private func handleOCRRecognitionModeChange() { + recognizedPipelineCacheKey = nil + recognizedPipelineCache = nil + ocrTextBlocks.removeAll() + textBlocks.removeAll() + if isOCRMagnificationVisible { + startOCRMagnification() + } + } + private func performImageLoadTask() async { guard retainsDecodedImage else { cancelLiveTranslationForPage()