Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions lib/core/services/debug_log_buffer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';

/// `debugPrint` 출력을 메모리에 모아두는 링 버퍼.
///
/// 분석 파이프라인은 진단에 결정적인 정보를 전부 `debugPrint`로만 뱉는다
/// (`[FrameExtractor] 추출 완료`, `[BallDetection] 검출 N/M ... 최고 점수`,
/// `[PinZone] 소스`, `[Trajectory] 리본 소스`, `[Speed] 랜드마크/기존/채택`).
/// TestFlight 빌드에는 콘솔이 없어 이게 전부 사라지므로, 실패·성공 시점에
/// 서버로 올릴 수 있도록 최근 [capacity]줄을 붙잡아둔다.
///
/// 내부 QA 전용 — 공개 배포 전 제거 대상.
class DebugLogBuffer {
DebugLogBuffer({this.capacity = 400});

/// 보관할 최대 줄 수. 분석 1회가 뱉는 줄 수(수십~200여 줄)를 넉넉히 덮는다.
final int capacity;

final List<String> _lines = <String>[];

static final DebugLogBuffer instance = DebugLogBuffer();

static DebugPrintCallback? _wrapper;

/// 전역 `debugPrint`를 감싸 버퍼에 적재한다. 원래 출력은 그대로 통과시킨다.
///
/// 이미 우리 래퍼가 걸려 있으면 아무것도 하지 않는다 — 중복 호출로 래퍼가
/// 겹겹이 쌓이면 한 줄이 여러 번 적재되고 원래 출력도 중복된다.
static void install() {
if (identical(debugPrint, _wrapper)) return;
final previous = debugPrint;
_wrapper = (String? message, {int? wrapWidth}) {
if (message != null) instance.add(message);
previous(message, wrapWidth: wrapWidth);
};
debugPrint = _wrapper!;
}

int get length => _lines.length;

void add(String line) {
_lines.add(line);
if (_lines.length > capacity) {
_lines.removeRange(0, _lines.length - capacity);
}
}

void clear() => _lines.clear();

/// 버퍼 내용을 하나의 문자열로. [maxChars]를 넘으면 **뒤쪽을 남긴다** —
/// 실패 직전 줄이 가장 중요하기 때문.
String dump({int maxChars = 60000}) {
final joined = _lines.join('\n');
if (joined.length <= maxChars) return joined;
return '…(앞부분 ${joined.length - maxChars}자 생략)\n'
'${joined.substring(joined.length - maxChars)}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'dart:io' show Platform;

import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'package:bowling_diary/core/services/debug_log_buffer.dart';

/// 분석 1회의 진단 결과를 Supabase `analysis_debug_logs`에 한 행으로 남긴다.
///
/// 목적 두 가지:
/// 1. TestFlight 실패 원인 추적 — 콘솔이 없어 `debugPrint`가 전부 사라진다.
/// 2. 구속 표본 축적 — 랜드마크/기존 두 코어 값을 매 투구마다 모아야
/// "20km/h가 맞나(스피드건 25)"를 표본 1개가 아닌 분포로 판단할 수 있다.
///
/// 내부 QA 전용 — 공개 배포 전 테이블과 함께 제거.
class AnalysisDebugLogService {
AnalysisDebugLogService({SupabaseClient? client, DebugLogBuffer? buffer})
: _client = client ?? Supabase.instance.client,
_buffer = buffer ?? DebugLogBuffer.instance;

final SupabaseClient _client;
final DebugLogBuffer _buffer;

static const String tableName = 'analysis_debug_logs';

/// 업로드 실패가 분석 흐름을 절대 깨지 않도록 모든 예외를 삼킨다 —
/// 진단 수집은 부가 기능이고, 여기서 던지면 원인 진단 자체가 불가능해진다.
Future<void> log({
required String outcome,
String? stage,
Object? error,
StackTrace? stack,
Map<String, dynamic>? metrics,
}) async {
try {
final userId = _client.auth.currentUser?.id;
if (userId == null) {
debugPrint('[AnalysisDebugLog] 미로그인 — 업로드 생략');
return;
}
await _client.from(tableName).insert(<String, dynamic>{
'user_id': userId,
'app_version': await _appVersion(),
'device': _device(),
'outcome': outcome,
'stage': stage,
'error': error?.toString(),
'stack': stack?.toString(),
'logs': _buffer.dump(),
'metrics': metrics,
});
debugPrint('[AnalysisDebugLog] 업로드 완료 ($outcome)');
} catch (e) {
debugPrint('[AnalysisDebugLog] 업로드 실패(무시): $e');
}
}

Future<String?> _appVersion() async {
try {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
} catch (e) {
return null;
}
}

String? _device() {
try {
return '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
} catch (e) {
return null;
}
}
}
106 changes: 101 additions & 5 deletions lib/features/analysis/presentation/pages/analysis_trim_page.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:flutter/material.dart';
Expand All @@ -6,6 +7,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
import 'package:bowling_diary/app/theme/app_colors.dart';
import 'package:bowling_diary/app/theme/app_text_styles.dart';
import 'package:bowling_diary/core/services/debug_log_buffer.dart';
import 'package:bowling_diary/features/analysis/data/services/analysis_debug_log_service.dart';
import 'package:bowling_diary/features/analysis/data/services/analysis_pipeline.dart';
import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart';
import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart';
Expand Down Expand Up @@ -50,6 +53,7 @@ class _AnalysisTrimPageState extends State<AnalysisTrimPage> {
double _totalSec = 0;
bool _isAnalyzing = false;
String? _trimmedPath;
final _debugLog = AnalysisDebugLogService();

@override
void initState() {
Expand Down Expand Up @@ -107,30 +111,41 @@ class _AnalysisTrimPageState extends State<AnalysisTrimPage> {

Future<void> _startAnalysis() async {
setState(() => _isAnalyzing = true);
// 실패 시 어느 단계에서 터졌는지 QA가 바로 알 수 있게 단계를 기록한다
// (기존에는 6개 실패 지점이 하나의 "문제가 발생했어요"로 뭉개졌다).
var stage = '영상 자르기';
// 이번 분석이 남긴 줄만 올라가도록 직전 세션 로그를 비운다.
DebugLogBuffer.instance.clear();
try {
final tempDir = await getTemporaryDirectory();
final trimmedPath = '${tempDir.path}/trimmed_${DateTime.now().millisecondsSinceEpoch}.mp4';
final trimSession = await FFmpegKit.execute(
'-i "${widget.videoPath}" -ss $_startSec -to $_endSec -c copy "$trimmedPath"',
);
final trimRc = await trimSession.getReturnCode();
if (trimRc == null || !trimRc.isValueSuccess()) throw Exception('영상 자르기 실패');
if (trimRc == null || !trimRc.isValueSuccess()) {
throw Exception('영상 자르기 실패 (rc: $trimRc)\n${await _ffmpegTail(trimSession)}');
}

if (mounted) setState(() => _trimmedPath = trimmedPath);

// 트림된 영상의 첫 프레임을 뽑아 레인 4코너를 자동검출한다(spec §10:
// 영상별 자동검출+확인 — 저장형 캘리브레이션 프로파일 폐기).
stage = '첫 프레임 추출';
final framePath = '${tempDir.path}/lane_frame_${DateTime.now().millisecondsSinceEpoch}.jpg';
final frameSession = await FFmpegKit.execute(
'-i "$trimmedPath" -frames:v 1 -q:v 3 "$framePath"',
);
final frameRc = await frameSession.getReturnCode();
if (frameRc == null || !frameRc.isValueSuccess()) throw Exception('첫 프레임 추출 실패');
if (frameRc == null || !frameRc.isValueSuccess()) {
throw Exception('첫 프레임 추출 실패 (rc: $frameRc)\n${await _ffmpegTail(frameSession)}');
}

final frameBytes = await File(framePath).readAsBytes();
final frame = img.decodeImage(frameBytes);
if (frame == null) throw Exception('첫 프레임 디코딩 실패');

stage = '레인 검출';
final detection = LaneDetectorService().detect(frame);

if (!mounted) return;
Expand All @@ -148,8 +163,10 @@ class _AnalysisTrimPageState extends State<AnalysisTrimPage> {
return;
}

stage = '호모그래피 계산';
final homography = HomographySolver.solve4Point(confirmedCorners, _laneCorners);

stage = '분석 파이프라인';
final pipeline = AnalysisPipeline(
frameExtractor: VideoFrameExtractorService(),
ballDetector: BallDetectionService(),
Expand All @@ -159,22 +176,101 @@ class _AnalysisTrimPageState extends State<AnalysisTrimPage> {
);
final analysisData = await pipeline.run(trimmedPath, homography);

// 성공 케이스도 남긴다 — 구속 두 코어 값이 매 투구 쌓여야 표본 1개가
// 아닌 분포로 정확도를 판단할 수 있다. await하지 않는다(결과 화면 지연 방지).
unawaited(_debugLog.log(
outcome: 'success',
metrics: <String, dynamic>{
'frames_analyzed': analysisData.framesAnalyzed,
'fps_used': analysisData.fpsUsed,
'speed_kmh': analysisData.speedKmh,
'landmark_speed_kmh': analysisData.landmarkSpeedKmh,
'legacy_speed_kmh': analysisData.legacySpeedKmh,
'speed_source': analysisData.speedSource?.name,
'speed_confidence': analysisData.speedConfidence,
'speed_failure': analysisData.speedFailure?.name,
'trajectory_source': analysisData.trajectorySource?.name,
'trajectory_fit_rms': analysisData.trajectoryFitRms,
'trajectory_points': analysisData.trajectory.length,
'entry_angle_deg': analysisData.entryAngleDeg,
'trim_start_sec': _startSec,
'trim_end_sec': _endSec,
},
));

if (!mounted) return;
await Navigator.pushReplacement(context, MaterialPageRoute(
builder: (_) => AnalysisResultPage(
analysisData: analysisData, videoPath: trimmedPath, recordedAt: DateTime.now(),
),
));
} catch (e) {
debugPrint('분석 실패: $e');
} catch (e, st) {
debugPrint('분석 실패 [$stage]: $e\n$st');
unawaited(_debugLog.log(
outcome: 'failure',
stage: stage,
error: e,
stack: st,
metrics: <String, dynamic>{
'trim_start_sec': _startSec,
'trim_end_sec': _endSec,
'source_fps': widget.fps,
},
));
if (!mounted) return;
setState(() => _isAnalyzing = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('분석 중 문제가 발생했어요. 다시 시도해 주세요')),
SnackBar(
content: Text('[$stage] 단계에서 실패했어요. 다시 시도해 주세요'),
duration: const Duration(seconds: 8),
action: SnackBarAction(
label: '자세히',
onPressed: () => _showFailureDetail(stage, e, st),
),
),
);
}
}

/// ffmpeg 실패 원인은 returnCode만으론 알 수 없다 — 마지막 로그 몇 줄을
/// 예외 메시지에 붙여 TestFlight에서도 원인이 보이게 한다.
Future<String> _ffmpegTail(dynamic session) async {
try {
final logs = await session.getLogs();
final lines = logs
.map((dynamic l) => l.getMessage() as String? ?? '')
.join()
.split('\n')
.where((String l) => l.trim().isNotEmpty)
.toList();
return lines.length <= 12 ? lines.join('\n') : lines.sublist(lines.length - 12).join('\n');
} catch (e) {
return '(로그 수집 실패: $e)';
}
}

/// 내부 QA용 — 실패 단계/예외/스택을 그대로 보여준다. 공개 배포 전 제거.
void _showFailureDetail(String stage, Object error, StackTrace st) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('분석 실패 — $stage'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: SelectableText(
'$error\n\n$st',
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')),
],
),
);
}

String _fmt(double seconds) {
final m = (seconds ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toStringAsFixed(1).padLeft(4, '0');
Expand Down
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:bowling_diary/app/app.dart' show AppRestarter, preloadTheme;
import 'package:bowling_diary/core/constants/supabase_constants.dart';
import 'package:bowling_diary/core/services/debug_log_buffer.dart';

void main() async {
// 분석 진단 로그를 서버로 올릴 수 있게 debugPrint를 버퍼에 적재한다
// (내부 QA 전용 — 공개 배포 전 제거).
DebugLogBuffer.install();

final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

Expand Down
2 changes: 1 addition & 1 deletion pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ packages:
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
Expand Down
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: bowling_diary
description: "볼링 기록 & 분석 앱 - 나의 볼링 성장 일기장"
publish_to: 'none'
version: 1.3.0+6
version: 1.3.0+7

environment:
sdk: ^3.5.0
Expand Down Expand Up @@ -50,6 +50,7 @@ dependencies:
phosphor_flutter: ^2.1.0
tflite_flutter: ^0.12.1
equatable: ^2.1.0
package_info_plus: ^9.0.1

dev_dependencies:
flutter_test:
Expand Down Expand Up @@ -91,5 +92,4 @@ flutter:
- assets/
- assets/icon/
- assets/splash/
- assets/videos/
- assets/models/
Loading
Loading