diff --git a/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt b/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt index 3b19bf4..a8fc1e4 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt @@ -1,10 +1,13 @@ package com.example.mini_tias import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.RenderMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + override fun getRenderMode(): RenderMode = RenderMode.texture + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) diff --git a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt index 9581a0e..017dbc2 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -1,20 +1,27 @@ package com.example.mini_tias +import android.app.Activity import android.content.Context +import android.graphics.Bitmap import android.graphics.ImageFormat +import android.graphics.Matrix import android.hardware.camera2.* import android.media.ImageReader import android.media.MediaScannerConnection import android.os.Handler import android.os.HandlerThread +import android.view.PixelCopy import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream /** * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで * フル解像度の画像を 1 フレームキャプチャする. */ -class RawCapturePlugin(private val context: Context) : MethodChannel.MethodCallHandler { +class RawCapturePlugin(private val activity: Activity) : MethodChannel.MethodCallHandler { + + private val context: Context = activity.applicationContext private var backgroundThread: HandlerThread? = null private var backgroundHandler: Handler? = null @@ -23,6 +30,7 @@ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "captureFullResolutionYuv" -> captureFullResolutionYuv(result) + "capturePreviewFrame" -> capturePreviewFrame(call, result) "scanFile" -> { val path = call.argument("path") if (path != null) { @@ -37,6 +45,57 @@ } } + /// PixelCopy で全画面をキャプチャし,指定領域を切り取って 180° 回転して返す. + private fun capturePreviewFrame(call: MethodCall, result: MethodChannel.Result) { + val x = call.argument("x") ?: 0 + val y = call.argument("y") ?: 0 + val cropWidth = call.argument("width") ?: 0 + val cropHeight = call.argument("height") ?: 0 + + val window = activity.window + val view = window.decorView + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + + val handler = Handler(activity.mainLooper) + PixelCopy.request(window, bitmap, { copyResult -> + if (copyResult == PixelCopy.SUCCESS) { + try { + val output = if (cropWidth > 0 && cropHeight > 0) { + // Dart 側で回転済み左上座標を計算済み + val cx = x.coerceIn(0, bitmap.width - 1) + val cy = y.coerceIn(0, bitmap.height - 1) + val cw = cropWidth.coerceAtMost(bitmap.width - cx) + val ch = cropHeight.coerceAtMost(bitmap.height - cy) + + // 切り取り + val cropped = Bitmap.createBitmap(bitmap, cx, cy, cw, ch) + bitmap.recycle() + + // 180° 回転(app.dart の回転を相殺) + val matrix = Matrix() + matrix.postRotate(180f) + val rotated = Bitmap.createBitmap(cropped, 0, 0, cropped.width, cropped.height, matrix, true) + cropped.recycle() + rotated + } else { + bitmap + } + + val stream = ByteArrayOutputStream() + output.compress(Bitmap.CompressFormat.JPEG, 85, stream) + output.recycle() + result.success(stream.toByteArray()) + } catch (e: Exception) { + bitmap.recycle() + result.error("PROCESS_ERROR", "スナップショット処理に失敗: ${e.message}", null) + } + } else { + bitmap.recycle() + result.error("PIXEL_COPY_FAILED", "PixelCopy に失敗: $copyResult", null) + } + }, handler) + } + private fun captureFullResolutionYuv(result: MethodChannel.Result) { startBackgroundThread() diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 7fa4a0b..e1f7f21 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/services/raw_capture_service.dart'; import 'package:mini_tias/widgets/camera_preview.dart'; import 'package:mini_tias/widgets/shutter_button.dart'; @@ -21,6 +23,8 @@ bool _timerEnabled = false; int? _countdown; Timer? _countdownTimer; + Uint8List? _snapshotBytes; + final _rawCaptureService = RawCaptureService(); @override void initState() { @@ -56,10 +60,40 @@ if (_timerEnabled) { _startCountdown(); } else { - cameraProvider.takePicture(); + _captureWithSnapshot(); } } + Future _captureWithSnapshot() async { + // PixelCopy でカメラ領域のスナップショットを瞬時に取得 + try { + if (!mounted) return; + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox != null) { + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + final ratio = View.of(context).devicePixelRatio; + + // localToGlobal は回転後の座標(右下)を返すため,左上を計算する + final topLeftX = position.dx - size.width; + final topLeftY = position.dy - size.height; + + final bytes = await _rawCaptureService.capturePreviewFrame( + x: (topLeftX * ratio).round(), + y: (topLeftY * ratio).round(), + width: (size.width * ratio).round(), + height: (size.height * ratio).round(), + ); + setState(() => _snapshotBytes = bytes); + } + } catch (_) { + // 失敗しても撮影は続行 + } + + if (!mounted) return; + context.read().takePicture(); + } + void _startCountdown() { setState(() => _countdown = 3); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -70,7 +104,7 @@ if (_countdown! <= 1) { timer.cancel(); setState(() => _countdown = null); - context.read().takePicture(); + _captureWithSnapshot(); } else { setState(() => _countdown = _countdown! - 1); } @@ -89,6 +123,7 @@ final fileName = cameraProvider.lastSavedFileName; if (fileName != null && fileName != _previousFileName) { _previousFileName = fileName; + _snapshotBytes = null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -132,15 +167,26 @@ return Center(child: Text(cameraProvider.errorMessage!)); } - if (!cameraProvider.isInitialized) { + if (!cameraProvider.isInitialized && + !cameraProvider.isSaving && + _snapshotBytes == null) { return const Center(child: CircularProgressIndicator()); } return Stack( children: [ - Positioned.fill( - child: CameraPreviewWidget(controller: cameraProvider.controller!), - ), + // 保存中はスナップショットを表示(Transform なし,Kotlin 側で回転済み) + if (_snapshotBytes != null && + (!cameraProvider.isInitialized || cameraProvider.isSaving)) + Positioned.fill( + child: Image.memory(_snapshotBytes!, fit: BoxFit.cover), + ) + // カメラプレビュー + else if (cameraProvider.isInitialized && + cameraProvider.controller != null) + Positioned.fill( + child: CameraPreviewWidget(controller: cameraProvider.controller!), + ), // 保存中インジケーター if (cameraProvider.isSaving) const Center( @@ -161,7 +207,6 @@ Center( child: Stack( children: [ - // 黒い縁取り Text( '$_countdown', style: TextStyle( @@ -173,10 +218,9 @@ ..color = Colors.black, ), ), - // 白いテキスト本体 Text( '$_countdown', - style: TextStyle( + style: const TextStyle( color: Colors.white, fontSize: 96, fontWeight: FontWeight.bold, diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 8a92d39..96faec2 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -5,11 +5,6 @@ static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); /// フロントカメラからフル解像度の YUV データをキャプチャする. - /// - /// 返り値は YUV データを含む Map: - /// - width, height: 画像サイズ - /// - yPlane, uPlane, vPlane: YUV プレーンのバイトデータ - /// - yRowStride, uvRowStride, uvPixelStride: ストライド情報 Future> captureFullResolutionYuv() async { final result = await _channel .invokeMethod('captureFullResolutionYuv') @@ -20,6 +15,23 @@ return Map.from(result as Map); } + /// PixelCopy でカメラプレビュー領域のスナップショットを JPEG として取得する. + Future capturePreviewFrame({ + required int x, + required int y, + required int width, + required int height, + }) async { + final result = await _channel.invokeMethod( + 'capturePreviewFrame', + {'x': x, 'y': y, 'width': width, 'height': height}, + ); + if (result == null) { + throw Exception('プレビューフレームの取得に失敗しました'); + } + return result; + } + /// MediaStore にファイルを登録し,PC から MTP で見えるようにする. Future scanFile(String path) async { await _channel.invokeMethod('scanFile', {'path': path});