diff --git a/.omm/data-flow/description.md b/.omm/data-flow/description.md index e7a517fe..6dd75040 100644 --- a/.omm/data-flow/description.md +++ b/.omm/data-flow/description.md @@ -13,3 +13,5 @@ CLI는 직접 인자 또는 base64url JSON을 받고 shell UID·허용 설정· 벤치마크 JSON은 기기 안에서만 생성하며, files/benchmarks와 baseline 목록으로 비교합니다. 외부 JSON 가져오기는 0.13.0에서 제거했습니다. Multi 모드의 ConcurrentSession은 독립 장치마다 출력과 저장을 관리합니다. 사진은 이미지·CaptureResult 센서 시각을 연결한 장치별 JPEG이며 다른 장치 영상을 합성하지 않습니다. ConcurrentPhotoStore는 JPEG를 DCIM/HALCamera에, 공통 촬영 ID·장치 ID·성공 또는 실패를 Download/HALCamera의 JSON에 기록합니다. 두 엔진은 MultiRecording을 공유하며, 하나의 마이크 AAC를 장치별 MP4에 결합한 뒤 공개합니다. 녹음이나 파일 하나라도 실패하면 해당 녹화 묶음의 공개 파일을 회수합니다. Split 배치는 프리뷰만 바꾸며 인코더 크기는 장치 스트림과 회전에서 정합니다. 이 경로는 Benchmark 계산과 분리됩니다. + +Portrait는 Depth 메뉴에서 선택하지만 별도 확장 세션의 컬러 미리보기와 OEM JPEG를 받습니다. PortraitCameraEngine은 JPEG를 MediaLibrary로 저장하고 효과·해상도·시각만 촬영 JSON에 남깁니다. 확장 콜백은 벤치마크나 일반 CaptureResult 지표에 섞지 않습니다. diff --git a/.omm/overall-architecture/camera-engines/description.md b/.omm/overall-architecture/camera-engines/description.md index fdd8a30e..d3fab77b 100644 --- a/.omm/overall-architecture/camera-engines/description.md +++ b/.omm/overall-architecture/camera-engines/description.md @@ -1,2 +1,2 @@ -camera/는 CameraEngine 인터페이스와 Camera2Engine·CameraXEngine·DepthCameraEngine을 제공합니다. Camera2Engine은 기본 크기 선택 경로와 벤치마크용 명시적 StreamSpec 경로를 구분합니다. 세션별 콜백과 close(done) 완료 통지가 러너에 연결됩니다. +camera/는 CameraEngine 인터페이스와 Camera2Engine·CameraXEngine·DepthCameraEngine·PortraitCameraEngine을 제공합니다. Camera2Engine은 기본 크기 선택 경로와 벤치마크용 명시적 StreamSpec 경로를 구분합니다. Depth는 맵 보기, Portrait는 제조사 Bokeh JPEG 촬영이며 벤치마크 엔진과 구분합니다. 세션별 콜백과 close(done) 완료 통지가 러너에 연결됩니다. StreamConfiguration은 실제 카메라 출력으로 Callback 목록을 만듭니다. 앱의 YUV JPEG 변환·PIP 합성·GPU 인코더 출력은 Callback에 추가하지 않습니다. diff --git a/.omm/overall-architecture/camera-engines/meta.yaml b/.omm/overall-architecture/camera-engines/meta.yaml index f6744b90..2777768f 100644 --- a/.omm/overall-architecture/camera-engines/meta.yaml +++ b/.omm/overall-architecture/camera-engines/meta.yaml @@ -7,6 +7,7 @@ title: camera-engines children: - camera2-engine - depth-engine + - portrait-engine - camerax-engine - endpoint-model - endpoint-resolver diff --git a/.omm/overall-architecture/camera-engines/portrait-engine/description.md b/.omm/overall-architecture/camera-engines/portrait-engine/description.md new file mode 100644 index 00000000..9f27bc79 --- /dev/null +++ b/.omm/overall-architecture/camera-engines/portrait-engine/description.md @@ -0,0 +1,5 @@ +PortraitCameraEngine은 API 31 이상에서 제조사 BOKEH의 CameraExtensionSession을 소유합니다. 공개 카메라의 확장 지원·SurfaceTexture 미리보기 크기·JPEG 크기를 각각 조회하고, 선택한 PortraitConfig의 두 크기로 세션을 구성합니다. 일반 StreamConfigurationMap 크기를 대용으로 쓰거나 실패 때 다른 크기로 재시도하지 않습니다. + +Live의 Depth 메뉴는 Off·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait는 컬러 미리보기와 JPEG 촬영이며 센서 Depth 맵을 반환하지 않습니다. 선택하면 Camera2로 전환하고 기존 close(done) 뒤 확장 세션을 엽니다. TextureView의 첫 표시 프레임 뒤 촬영을 허용합니다. 미리보기 15초·JPEG 60초 시간 제한, 실패 후 해제, 늦은 JPEG 차단과 단일 촬영의 한 번뿐인 완료 전달을 둡니다. close(done)는 열기 중인 장치도 닫고 CameraDevice.onClosed 뒤 자원을 회수합니다. 이미 받은 JPEG의 저장 작업은 완료할 수 있습니다. + +사진은 OEM JPEG를 그대로 MediaLibrary로 저장하고 JPEG 크기·미리보기 크기·manufacturer_bokeh 효과·시각을 촬영 JSON에 기록합니다. 수동 제어·줌·터치 측광·연사·AEB·RAW·PIP·동영상은 제공하지 않습니다. 일반 Camera2 콜백 지표로 확장 처리를 측정하지 않으며 Benchmark와 별도 경로입니다. Photo·Video·카메라·엔진 전환과 새 CLI 촬영 준비는 Portrait를 종료합니다. diff --git a/.omm/overall-architecture/camera-engines/portrait-engine/meta.yaml b/.omm/overall-architecture/camera-engines/portrait-engine/meta.yaml new file mode 100644 index 00000000..e5e6ed05 --- /dev/null +++ b/.omm/overall-architecture/camera-engines/portrait-engine/meta.yaml @@ -0,0 +1,5 @@ +kind: nested-class +title: portrait-engine +children: [] +parentPath: + - camera-engines diff --git a/.omm/overall-architecture/screens/expert-screen/camera-picker/description.md b/.omm/overall-architecture/screens/expert-screen/camera-picker/description.md new file mode 100644 index 00000000..ad678523 --- /dev/null +++ b/.omm/overall-architecture/screens/expert-screen/camera-picker/description.md @@ -0,0 +1 @@ +MainCameraPicker는 Live의 공개 카메라 선택과 공통 CameraLabel·배율 표시를 담당합니다. 녹화·저장·CLI·종료 처리 중 선택을 막고, 카메라가 바뀌면 Depth·Portrait를 종료하며 줌과 렌즈별 제어를 초기화합니다. restartCamera는 기존 엔진의 close(done) 뒤 선택한 카메라를 엽니다. 버튼과 핀치는 applyZoom을 공유하며 Depth·Portrait에서는 배율 변경을 거절합니다. diff --git a/.omm/overall-architecture/screens/expert-screen/camera-picker/meta.yaml b/.omm/overall-architecture/screens/expert-screen/camera-picker/meta.yaml new file mode 100644 index 00000000..deea4a7c --- /dev/null +++ b/.omm/overall-architecture/screens/expert-screen/camera-picker/meta.yaml @@ -0,0 +1,6 @@ +kind: nested-class +title: camera-picker +children: [] +parentPath: + - screens + - expert-screen diff --git a/.omm/overall-architecture/screens/expert-screen/meta.yaml b/.omm/overall-architecture/screens/expert-screen/meta.yaml index 678dba1e..ea2f869c 100644 --- a/.omm/overall-architecture/screens/expert-screen/meta.yaml +++ b/.omm/overall-architecture/screens/expert-screen/meta.yaml @@ -4,6 +4,7 @@ update_count: 2 last_field: concern kind: nested-class title: expert-screen -children: [] +children: + - camera-picker parentPath: - screens diff --git a/.omm/state-transitions/engine-lifecycle/meta.yaml b/.omm/state-transitions/engine-lifecycle/meta.yaml index f7a72291..7adbcf22 100644 --- a/.omm/state-transitions/engine-lifecycle/meta.yaml +++ b/.omm/state-transitions/engine-lifecycle/meta.yaml @@ -6,4 +6,5 @@ kind: nested-class title: engine-lifecycle children: - camerax-lifecycle + - portrait-lifecycle parentPath: [] diff --git a/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md new file mode 100644 index 00000000..cb948315 --- /dev/null +++ b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md @@ -0,0 +1 @@ +Depth 메뉴의 Off·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait 선택은 기존 close(done) 뒤 CameraExtensionSession을 열며, 첫 표시 프레임 이후 셔터를 허용합니다. JPEG를 받기 전 종료되면 촬영을 실패로 한 번 완료하고, 이미 받은 JPEG의 저장은 마칠 수 있습니다. Photo·Video·카메라·엔진 전환과 새 CLI 촬영은 Portrait를 종료합니다. 실패 뒤에도 Photo 복귀와 다른 해상도 선택을 허용합니다. diff --git a/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/meta.yaml b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/meta.yaml new file mode 100644 index 00000000..52f9c592 --- /dev/null +++ b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/meta.yaml @@ -0,0 +1,5 @@ +kind: nested-class +title: portrait-lifecycle +children: [] +parentPath: + - engine-lifecycle diff --git a/.omm/ui-tool-handoff/description.md b/.omm/ui-tool-handoff/description.md index f3d1ff3a..faefc451 100644 --- a/.omm/ui-tool-handoff/description.md +++ b/.omm/ui-tool-handoff/description.md @@ -9,3 +9,5 @@ Live Streams는 Lab 또는 Live의 크기 표시에서 열 수 있으며 카메 Multi도 Live의 close(done) 뒤 ConcurrentCameraActivity를 엽니다. Live에서 선택한 엔진을 전달하며 Multi 화면에서도 Camera2와 CameraX를 바꿀 수 있습니다. 이 화면은 모든 카메라의 onClosed를 기다린 뒤 Live로 돌아가며, Live는 기존 엔진 선택으로 프리뷰를 다시 엽니다. Depth 보기에서도 Lab 진입 전에 close(done)를 기다립니다. 촬영 모드를 바꾸지 않고 Lab에서 돌아오면 선택한 Depth 해상도를 다시 엽니다. Depth 보기 중에는 크기 표시로 Live Streams를 여는 동작을 비활성화합니다. + +Portrait에서도 Lab 진입 전에 close(done)를 기다리고 복귀 때 선택 해상도를 다시 엽니다. Portrait의 크기 표시를 누르면 일반 Live Streams 대신 Depth 메뉴를 엽니다. Lab에서 일반 Live Streams 설정을 적용하면 Portrait를 종료하고 해당 설정을 사용합니다. diff --git a/.omm/ui-zoom/description.md b/.omm/ui-zoom/description.md index b936e19a..5a8276af 100644 --- a/.omm/ui-zoom/description.md +++ b/.omm/ui-zoom/description.md @@ -2,7 +2,7 @@ Live 프리뷰에서 두 손가락을 벌리거나 모으면 지원 범위 안에서 연속으로 줌을 바꿉니다. 가장 가까운 프리셋의 원에 현재 배율을 소수 첫째 자리까지 표시하며, 펼친 상태에서 그 원을 누르면 해당 프리셋 배율로 돌아갑니다. 화면의 카메라 선택 정보를 갱신해도 중간 배율은 유지합니다. -핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. +핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기·제조사 Portrait, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. | 조건 | 동작 | | --- | --- | diff --git a/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt b/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt index 1330e9d5..a4a992fe 100644 --- a/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt +++ b/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt @@ -19,6 +19,9 @@ class CliStoreInstrumentation : Instrumentation() { private var lintChecks = false private var splitDrag = false private var depthChecks = false + private var portraitChecks = false + private var portraitMetadata = false + private var portraitUi = false override fun onCreate(arguments: Bundle?) { super.onCreate(arguments) exportReports = arguments?.getString("export_reports") == "true" @@ -31,10 +34,26 @@ class CliStoreInstrumentation : Instrumentation() { lintChecks = arguments?.getString("lint_checks") == "true" splitDrag = arguments?.getString("split_drag") == "true" depthChecks = arguments?.getString("depth_checks") == "true" + portraitChecks = arguments?.getString("portrait_checks") == "true" + portraitMetadata = arguments?.getString("portrait_metadata") == "true" + portraitUi = arguments?.getString("portrait_ui") == "true" start() } override fun onStart() { + if (portraitChecks || portraitMetadata || portraitUi) { + try { + val result = when { + portraitMetadata -> dev.halcamera.ui.PortraitChecks.metadata(this) + portraitUi -> dev.halcamera.ui.PortraitChecks.uiChecks(this) + else -> dev.halcamera.ui.PortraitChecks.run(this) + } + finish(Activity.RESULT_OK, Bundle().apply { putString("stream", "$result\n") }) + } catch (error: Throwable) { + finish(Activity.RESULT_CANCELED, Bundle().apply { putString("stream", "PORTRAIT_CHECKS_FAILED: ${error.stackTraceToString()}\n") }) + } + return + } if (depthChecks) { try { val result = dev.halcamera.ui.DepthMapChecks.run(this) diff --git a/app/src/androidTest/java/dev/halcamera/ui/PinchZoomChecks.kt b/app/src/androidTest/java/dev/halcamera/ui/PinchZoomChecks.kt index 1359e475..89156e5b 100644 --- a/app/src/androidTest/java/dev/halcamera/ui/PinchZoomChecks.kt +++ b/app/src/androidTest/java/dev/halcamera/ui/PinchZoomChecks.kt @@ -9,6 +9,7 @@ import android.view.MotionEvent import android.view.View import android.widget.Button import dev.halcamera.MainActivity +import dev.halcamera.updateCameraChoices import dev.halcamera.camera.zoomRange import dev.halcamera.telemetry.FlightRecorder import java.io.File diff --git a/app/src/androidTest/java/dev/halcamera/ui/PortraitChecks.kt b/app/src/androidTest/java/dev/halcamera/ui/PortraitChecks.kt new file mode 100644 index 00000000..68597533 --- /dev/null +++ b/app/src/androidTest/java/dev/halcamera/ui/PortraitChecks.kt @@ -0,0 +1,190 @@ +package dev.halcamera.ui + +import android.app.Instrumentation +import android.content.Intent +import android.graphics.BitmapFactory +import android.os.Build +import android.os.SystemClock +import dev.halcamera.MainActivity +import dev.halcamera.chooseDepth +import dev.halcamera.selectMode +import dev.halcamera.camera.* +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** Opt-in hardware checks. Saves ordinary OEM JPEGs to MediaLibrary, never pixels to the report. */ +object PortraitChecks { + fun uiChecks(test: Instrumentation): String { + fun ui(block: () -> T): T { + val result = AtomicReference>() + test.runOnMainSync { result.set(runCatching(block)) } + return result.get().getOrThrow() + } + fun nodes(root: android.view.View): Sequence = sequence { + yield(root) + if (root is android.view.ViewGroup) for (index in 0 until root.childCount) yieldAll(nodes(root.getChildAt(index))) + } + fun roots() = android.view.inspector.WindowInspector.getGlobalWindowViews().filter { it.isShown } + fun text(value: String) = roots().asReversed().asSequence().flatMap(::nodes) + .filterIsInstance().firstOrNull { it.text.toString() == value } + fun click(value: String) { + var target: android.view.View = requireNotNull(text(value)) + while (!target.isClickable) target = target.parent as android.view.View + check(target.isEnabled); check(target.performClick()) + } + fun waitFor(label: String, condition: () -> Boolean) { + val end = SystemClock.uptimeMillis() + 25_000 + while (!ui(condition)) { + check(SystemClock.uptimeMillis() < end) { "Timed out: $label" }; SystemClock.sleep(100) + } + } + val activity = test.startActivitySync(Intent(test.targetContext, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) as MainActivity + val directory = File(test.targetContext.getExternalFilesDir(null), "portrait-ui").apply { mkdirs() } + fun screenshot(name: String) = ui { + // App-owned windows only; camera pixels and SystemUI notifications are excluded. + val alpha = activity.previewHost.alpha + val thumbnails = nodes(activity.window.decorView).filterIsInstance().map { it to it.alpha }.toList() + activity.previewHost.alpha = 0f + thumbnails.forEach { it.first.alpha = 0f } + val bitmap = android.graphics.Bitmap.createBitmap(activity.window.decorView.width, activity.window.decorView.height, + android.graphics.Bitmap.Config.ARGB_8888) + try { + val canvas = android.graphics.Canvas(bitmap); canvas.drawColor(android.graphics.Color.BLACK) + roots().forEach { root -> + val xy = IntArray(2); root.getLocationOnScreen(xy) + canvas.save(); canvas.translate(xy[0].toFloat(), xy[1].toFloat()); root.draw(canvas); canvas.restore() + } + File(directory, name).outputStream().use { check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)) } + } finally { activity.previewHost.alpha = alpha; thumbnails.forEach { it.first.alpha = it.second }; bitmap.recycle() } + } + fun enterPortrait() { + ui { activity.chooseDepth() } + waitFor("Depth menu") { text("Portrait (Bokeh)") != null } + screenshot("depth-portrait-menu.png") + ui { click("Portrait (Bokeh)") } + waitFor("Portrait resolution picker") { text("JPEG 640x480") != null } + screenshot("portrait-resolutions.png") + ui { click("JPEG 640x480") } + waitFor("Portrait preview") { activity.portraitConfig != null && activity.ready && !activity.closing } + ui { check(activity.depthModeButton.isSelected); check(activity.mediaButton.isEnabled); check(!activity.zoomControl.isEnabled) } + } + try { + waitFor("initial Photo") { activity.ready && !activity.closing } + enterPortrait() + screenshot("live-portrait.png") + ui { activity.selectMode(true) } + waitFor("Video recovery") { activity.ready && activity.videoMode && activity.portraitConfig == null } + ui { activity.selectMode(false) } + waitFor("Photo recovery") { activity.ready && !activity.videoMode && activity.portraitConfig == null } + enterPortrait() + ui { activity.chooseDepth() } + waitFor("Depth menu Off") { text("Off") != null } + ui { click("Off") } + waitFor("Off recovery") { activity.ready && activity.portraitConfig == null && activity.depthSize == null } + } finally { ui { activity.finish() } } + return "PORTRAIT_UI_CHECKS_OK · Depth entry, resolution picker, selected controls, Photo/Video/Off recovery · $directory" + } + + fun metadata(test: Instrumentation): String { + check(Build.VERSION.SDK_INT >= 31) + val manager = test.targetContext.getSystemService(android.hardware.camera2.CameraManager::class.java) + val values = manager.cameraIdList.map { id -> + val extension = manager.getCameraExtensionCharacteristics(id) + val result = runCatching { extension.getExtensionSupportedSizes( + android.hardware.camera2.CameraExtensionCharacteristics.EXTENSION_BOKEH, android.graphics.ImageFormat.DEPTH_JPEG) + .map { it.toString() } } + mapOf("camera" to id, "bokehDepthJpegSizes" to result.getOrNull(), "readError" to result.exceptionOrNull()?.toString()) + } + val output = File(test.targetContext.getExternalFilesDir(null), "portrait-depth-metadata.json") + output.writeText(JSONObject(mapOf("model" to Build.MODEL, "api" to Build.VERSION.SDK_INT, "cameras" to values)).toString(2)) + return "PORTRAIT_METADATA_OK · ${output.absolutePath}" + } + + fun run(test: Instrumentation): String { + check(Build.VERSION.SDK_INT >= 31) + fun ui(block: () -> T): T { + val result = AtomicReference>() + test.runOnMainSync { result.set(runCatching(block)) } + return result.get().getOrThrow() + } + val activity = test.startActivitySync(Intent(test.targetContext, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) as MainActivity + val report = File(test.targetContext.getExternalFilesDir(null), "portrait-checks.json") + val checks = mutableListOf>() + fun persist() = report.writeText(JSONObject(mapOf("model" to Build.MODEL, "android" to Build.VERSION.RELEASE, + "api" to Build.VERSION.SDK_INT, "checks" to checks)).toString(2)) + fun waitReady(label: String) { + val end = SystemClock.uptimeMillis() + 25_000 + while (!ui { activity.ready && !activity.closing }) { + check(SystemClock.uptimeMillis() < end) { "Timed out: $label" } + SystemClock.sleep(100) + } + } + fun configure(id: String, config: PortraitConfig?) { + ui { + activity.cameraId = id; activity.engineName = "Camera2"; activity.videoMode = false + activity.resetModeSettings(); activity.portraitConfig = config; activity.paused = false + activity.restartCamera() + } + waitReady("camera $id / $config") + } + try { + waitReady("initial Photo") + val manager = activity.manager + val ids = manager.cameraIdList.filter { portraitSupport(manager, it).available } + check(ids.isNotEmpty()) { "No OEM Bokeh cameras connected" } + for (id in ids) { + val support = portraitSupport(manager, id) + val largest = support.jpegs.filter { it.width.toLong() * it.height <= 16_000_000 }.lastOrNull() ?: support.jpegs.first() + val sizes = listOf(support.jpegs.first(), largest).distinct() + for (size in sizes) { + val config = requireNotNull(support.config(size)) + configure(id, config) + check(ui { activity.engine is PortraitCameraEngine }) + checks += mapOf("scenario" to "preview", "camera" to id, "preview" to config.preview.toString(), + "jpeg" to size.toString(), "result" to "PASS") + persist() + } + val latch = CountDownLatch(1) + val captured = AtomicReference>() + ui { (activity.engine as MediaCapture).capturePhoto("portrait-device-$id") { captured.set(it); latch.countDown() } } + check(latch.await(65, TimeUnit.SECONDS)) { "JPEG wait timed out for $id" } + val photo = captured.get().getOrThrow() + val file = photo.artifacts.single { it.mime == "image/jpeg" } + val bytes = test.targetContext.contentResolver.openInputStream(file.uri)!!.use { it.readBytes() } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + check(setOf(bounds.outWidth, bounds.outHeight) == setOf(largest.width, largest.height)) { "JPEG resolution mismatch" } + val metadata = photo.artifacts.single { it.mime == "application/json" } + val text = test.targetContext.contentResolver.openInputStream(metadata.uri)!!.bufferedReader().use { it.readText() } + check(JSONObject(text).getJSONObject("capture").getString("effect") == "manufacturer_bokeh") + checks += mapOf("scenario" to "capture", "camera" to id, "jpeg" to largest.toString(), "name" to file.name, + "bytes" to bytes.size, "sha256" to MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }, + "encodedWidth" to bounds.outWidth, "encodedHeight" to bounds.outHeight, + "exifOrientation" to JpegOrientation.read(bytes), "effect" to "manufacturer_bokeh", "result" to "PASS") + configure(id, null) + checks += mapOf("scenario" to "return_to_photo", "camera" to id, "result" to "PASS") + persist() + } + // Repeated OEM/ordinary ownership transfer, plus a close immediately after open was requested. + val config = requireNotNull(portraitSupport(manager, ids.first()).config(portraitSupport(manager, ids.first()).jpegs.first())) + repeat(3) { configure(ids.first(), config); configure(ids.first(), null) } + ui { + activity.portraitConfig = config; activity.restartCamera() + activity.portraitConfig = null; activity.restartCamera() + } + waitReady("cancelled Portrait opening") + checks += mapOf("scenario" to "three_reopens_and_cancel_open", "result" to "PASS") + persist() + } catch (error: Throwable) { + checks += mapOf("scenario" to "failure", "error" to error.stackTraceToString(), "result" to "FAIL") + persist(); throw error + } finally { ui { activity.finish() } } + return "PORTRAIT_CHECKS_OK · ${checks.size} checks · ${report.absolutePath}" + } +} diff --git a/app/src/main/java/dev/halcamera/MainActivity.kt b/app/src/main/java/dev/halcamera/MainActivity.kt index 0fa42b67..4e4d7b9b 100644 --- a/app/src/main/java/dev/halcamera/MainActivity.kt +++ b/app/src/main/java/dev/halcamera/MainActivity.kt @@ -122,15 +122,16 @@ class MainActivity : ComponentActivity() { private lateinit var modeControls: LinearLayout private lateinit var recordingTime: TextView private lateinit var galleryButton: RecentMediaButton - private lateinit var cameraShortcut: IconButton + internal lateinit var cameraShortcut: IconButton /** Takes a photo from the running recording (#175); it sits where the camera picker is while recording. */ private lateinit var snapshotButton: IconButton - private var cameraIds = emptyList() + internal var cameraIds = emptyList() /** Logical id to endpoint, so [cameraLabel] can name the lens without re-reading CameraCharacteristics. */ - private var cameraEndpoints = emptyMap() + internal var cameraEndpoints = emptyMap() private lateinit var labButton: Button internal var videoMode = false internal var depthSize: LiveSize? = null + internal var portraitConfig: PortraitConfig? = null internal lateinit var depthModeButton: Button internal var recordingVideo = false internal var stoppingRecording = false @@ -139,14 +140,14 @@ class MainActivity : ComponentActivity() { internal val captureFeedback by lazy { CaptureFeedback(this) } internal val bursts by lazy { LiveBurst(SystemClock::elapsedRealtime, { d, b -> main.postDelayed(b, d) }, - { engine as? MediaCapture }, { ready && cli.active == null && !videoMode }, telemetry::event, ::updateMediaControls, + { engine as? MediaCapture }, { ready && cli.active == null && !videoMode && portraitConfig == null }, telemetry::event, ::updateMediaControls, { if (resumed) captureFeedback.showResult(it) }, BracketFusion(this)) } private val mediaPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val action = pendingPermissionAction.also { pendingPermissionAction = null } if (grants.values.all { it }) action?.invoke() else toast("Allow the requested permissions to save media.") } - private lateinit var engineButton: Button + internal lateinit var engineButton: Button internal lateinit var physicalPipButton: Button internal var pendingPip: PipSource? = null internal val pipUi by lazy { LivePipController(this,{ previewHost },{ engine as? PipCamera },::updateMediaControls,{ lastPreviewFrameNs = nowNs() },::showNotice,{ cameraId }) } @@ -176,6 +177,7 @@ class MainActivity : ComponentActivity() { data.getStringExtra(WorkbenchActivity.EXTRA_CAMERA_ID) == cameraId) { @Suppress("DEPRECATION") val settings = data.getSerializableExtra(LiveStreamsActivity.EXTRA_SETTINGS) as? LiveStreamSettings + portraitConfig = null if (settings == null) streamSettings.remove(streamKey()) else streamSettings[streamKey()] = settings telemetry.event(sessionId, "live_streams_changed", settings?.metadata() ?: mapOf("mode" to "default")) paused = false @@ -226,7 +228,7 @@ class MainActivity : ComponentActivity() { liveIndicator.bindSizes(telemetry.sessions[sessionId]?.get("negotiatedStreams") as? Map<*, *>) } readings.update(events, frames, time, sessionId, controlBar.controls) - manualPanel.bind(controlBar.controls.manual, depthSize == null && (ready || recordingVideo) && !stoppingRecording && cli.active == null && !bursts.controlsLocked, + manualPanel.bind(controlBar.controls.manual, depthSize == null && portraitConfig == null && (ready || recordingVideo) && !stoppingRecording && cli.active == null && !bursts.controlsLocked, manualCapabilities, frames.lastOrNull(), time) manualBack.isEnabled = manualPanel.isExpanded if (callbackGraph.isVisible) callbackGraph.updateLive(events,sessionId,time,telemetry.sessions,(engine as? PipCamera)?.pipCallbackSession) @@ -251,6 +253,8 @@ class MainActivity : ComponentActivity() { videoMode = savedInstanceState?.getBoolean("videoMode") ?: false @Suppress("DEPRECATION") depthSize = savedInstanceState?.getSerializable("depthSize") as? LiveSize + @Suppress("DEPRECATION") + portraitConfig = savedInstanceState?.getSerializable("portraitConfig") as? PortraitConfig @Suppress("DEPRECATION", "UNCHECKED_CAST") (savedInstanceState?.getSerializable("liveStreams") as? HashMap)?.let(streamSettings::putAll) @Suppress("DEPRECATION", "UNCHECKED_CAST") @@ -266,6 +270,7 @@ class MainActivity : ComponentActivity() { outState.putString("engine", engineName); outState.putString("camera", cameraId); outState.putBoolean("paused", paused); outState.putFloat("zoom", zoomRatio) outState.putBoolean("videoMode", videoMode) outState.putSerializable("depthSize", depthSize) + outState.putSerializable("portraitConfig", portraitConfig) outState.putSerializable("liveStreams", HashMap(streamSettings)) outState.putSerializable("goodStreams", HashMap(goodStreams)) outState.putBoolean("returningFromLab", returningFromLab) @@ -365,7 +370,7 @@ class MainActivity : ComponentActivity() { } if (cameraId.isEmpty()) { setStatus("No cameras available.", false); return } sessionId = UUID.randomUUID().toString() - if (depthSize == null && pendingPip == null && engineCameraId == cameraId) pendingPip = pipUi.selected + if (depthSize == null && portraitConfig == null && pendingPip == null && engineCameraId == cameraId) pendingPip = pipUi.selected previewStartup.reset() engineCameraId = cameraId val thisSession = sessionId @@ -399,6 +404,8 @@ class MainActivity : ComponentActivity() { val status = { text: String, ok: Boolean -> if (thisSession == sessionId && resumed && !closing) setStatus(text,ok) } engine = if (depthSize != null) { createDepthEngine(released, status) + } else if (portraitConfig != null) { + createPortraitEngine(released, status) } else if (engineName == "CameraX") { val view = PreviewView(this).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE; scaleType = PreviewView.ScaleType.FILL_CENTER } previewHost.addView(view, FrameLayout.LayoutParams(-1,-1)) @@ -449,7 +456,7 @@ class MainActivity : ComponentActivity() { } }, recordingState = recordingState, notice = notice, status = status) } - if (depthSize == null) previewHost.addView(FocusRing(this, { engine as? TouchMetering }) { controlBar.setAeLock(it) }.apply { + if (depthSize == null && portraitConfig == null) previewHost.addView(FocusRing(this, { engine as? TouchMetering }) { controlBar.setAeLock(it) }.apply { canInteract = { !bursts.controlsLocked && cli.active == null } onZoomScale = { factor -> if (factor.isFinite() && factor > 0f) applyZoom(zoomRatio * factor) @@ -463,7 +470,7 @@ class MainActivity : ComponentActivity() { try { engine?.start() } catch (e: Exception) { setStatus("Start failed: ${e.message}",false) } updateCameraChoices() } - internal fun depthFrameArrived() { + internal fun previewFrameArrived() { lastPreviewFrameNs = nowNs() initialPreviewArrived() } @@ -560,7 +567,7 @@ class MainActivity : ComponentActivity() { it.includeFontPadding=false it.minWidth=dp(48); it.minimumWidth=dp(48) } - liveIndicator=LiveIndicator(this).apply { onSizesClick = ::openLiveStreams } + liveIndicator=LiveIndicator(this).apply { onSizesClick = { if (portraitConfig != null) chooseDepth() else openLiveStreams() } } val leadingSlot=LinearLayout(this).apply { orientation=LinearLayout.HORIZONTAL; gravity=Gravity.START or Gravity.CENTER_VERTICAL; isBaselineAligned=false addView(engineButton,LinearLayout.LayoutParams(-2,dp(48))) @@ -640,7 +647,7 @@ class MainActivity : ComponentActivity() { withMediaPermissions(true) { (engine as? MediaCapture)?.startRecording() } } else withMediaPermissions(false) { controlBar.controls.let { if (it.bracket) bursts.bracket(sessionId, it, controlBar.support) else engine?.capture() } } } - bindBurstInput({ !videoMode && !mediaBusy() && !controlBar.controls.bracket && cli.active == null }, + bindBurstInput({ !videoMode && portraitConfig == null && !mediaBusy() && !controlBar.controls.bracket && cli.active == null }, { withMediaPermissions(false,it) }, { bursts.hold(sessionId) }, bursts::release) } captureRow.addView(mediaButton,LinearLayout.LayoutParams(captureSize,captureSize).apply { marginStart=dp(12); marginEnd=dp(12) }) @@ -702,60 +709,14 @@ class MainActivity : ComponentActivity() { updateCameraChoices() updateMediaControls() } - private fun selectCamera() { - if (cli.active != null || recordingVideo || mediaBusy() || closing) return - val ids=cameraIds.sortedWith(CameraLabel.idOrder) - val choices=ids.map { id -> - val endpoint=cameraEndpoints[id] - val name=endpoint?.let { listOfNotNull(CameraLabel.facing(it.role,it.facing),CameraLabel.lens(it.role), - CameraLabel.angle(it.role,it.equivalentFocalMm)).joinToString(" · ") }?.takeIf { it.isNotEmpty() } ?: "Camera" - LiveChoiceSheet.Choice("Service · ID $id",name) - } - LiveChoiceSheet.show(this,"Camera",choices,ids.indexOf(cameraId)) { index -> - if (recordingVideo || !resumed || cli.active != null || mediaBusy() || closing) return@show - val chosen=ids[index] - if (cameraId!=chosen) { - pendingPermissionAction=null - recorder.finish("camera_changed")?.let(incidents::export) - cameraId=chosen; zoomRatio=1f - depthSize=null - resetControls(); updateCameraChoices(); restartCamera() - } - } - } - /** Share lens labels with Benchmark, Probe and History; unknown IDs retain the short label. */ - private fun cameraLabel(id:String):String { - if(id.isEmpty()) return "No camera" - return cameraEndpoints[id]?.let(CameraLabel::full) ?: CameraLabel.short(id) - } - private fun applyZoom(ratio: Float) { - if (depthSize != null || !resumed || paused || closing || cli.active != null || bursts.controlsLocked || - !(ready || recordingVideo) || stoppingRecording || cameraId.isEmpty() || !ratio.isFinite()) return - val range = zoomRange(manager, cameraId) - zoomRatio = ratio.coerceIn(range.first, range.second) - engine?.setZoom(zoomRatio) - zoomControl.setRatio(zoomRatio) - } - - internal fun updateCameraChoices() { - if(cameraId.isNotEmpty()) { - val range=zoomRange(manager,cameraId) - zoomRatio=zoomRatio.coerceIn(range.first,range.second) - } - engineButton.text=engineName - engineButton.contentDescription="Current: $engineName; tap to switch to ${if(engineName=="Camera2") "CameraX" else "Camera2"}" - cameraShortcut.contentDescription="Choose camera, current: ${cameraLabel(cameraId)}" - cameraShortcut.tooltipText=cameraShortcut.contentDescription - zoomControl.setChoices(if(cameraId.isEmpty()) listOf(1f) else zoomPresets(zoomRange(manager,cameraId)),zoomRatio) - } internal fun updateMediaControls() { if (!resumed || paused || closing || engine == null) liveIndicator.bind(false) val idle=!recordingVideo && bursts.run==null cli.setUiBusy(liveCli, recordingVideo || stoppingRecording || pendingPermissionAction != null || mediaBusy()) listOf(photoModeButton,videoModeButton).forEachIndexed { index, button -> - val selected=depthSize == null && (index==1)==videoMode + val selected=depthSize == null && portraitConfig == null && (index==1)==videoMode button.isSelected=selected - button.isEnabled=(ready || depthSize != null) && idle && !closing && !mediaBusy() && pendingPermissionAction == null + button.isEnabled=(ready || depthSize != null || portraitConfig != null) && idle && !closing && !mediaBusy() && pendingPermissionAction == null button.setTextColor(if(selected) Look.onDark else Look.onDarkMuted) button.setTypeface(null,if(selected) Typeface.BOLD else Typeface.NORMAL) button.contentDescription=if(index==0) "Photo mode: save selected JPEG and RAW outputs" else "Video mode with audio" @@ -781,8 +742,8 @@ class MainActivity : ComponentActivity() { mediaButton.isEnabled = false mediaButton.contentDescription = "Photo output is off: enable JPEG or RAW in Live streams" } - engineButton.isEnabled=idle - cameraShortcut.isEnabled=idle && cameraId.isNotEmpty() + engineButton.isEnabled=idle && !closing && !mediaBusy() && pendingPermissionAction == null + cameraShortcut.isEnabled=engineButton.isEnabled && cameraId.isNotEmpty() val snapshot=(engine as? MediaCapture)?.snapshot ?: SnapshotStatus.NONE cameraShortcut.visibility=if(recordingVideo) View.GONE else View.VISIBLE snapshotButton.visibility=if(recordingVideo) View.VISIBLE else View.GONE @@ -797,7 +758,7 @@ class MainActivity : ComponentActivity() { zoomControl.isEnabled=(ready || recordingVideo) && !stoppingRecording && !bursts.controlsLocked controlBar.bind(videoMode,depthSize == null && (ready || recordingVideo) && !stoppingRecording && cli.active==null && !bursts.controlsLocked, pipUi.selected != null || pipUi.busy) - galleryButton.isEnabled=idle + galleryButton.isEnabled=idle && !mediaBusy() labButton.isEnabled=!recordingVideo && !stoppingRecording && !closing && !mediaBusy() liveIndicator.setSizesEnabled(labButton.isEnabled && cli.active == null && pendingPermissionAction == null && cameraId.isNotEmpty()) listOf(multiButton,multiVideoButton).forEach { @@ -817,17 +778,19 @@ class MainActivity : ComponentActivity() { listOf(mediaButton, engineButton, cameraShortcut, photoModeButton, videoModeButton, zoomControl, galleryButton, labButton, reportButton).forEach { it.isEnabled = false } } updateDepthControls(callbackGraph.isVisible, topBar.height + dp(8), bottomBar.height + dp(64)) + updatePortraitControls() listOf(engineButton,cameraShortcut,photoModeButton,videoModeButton,mediaButton,galleryButton,labButton).forEach { it.alpha=if(it.isEnabled) 1f else 0.4f } } private fun chooseEngine(name:String) { - if(engineName==name) return + if(engineName==name || !resumed || closing || cli.active != null || recordingVideo || stoppingRecording || mediaBusy()) return recorder.finish("engine_changed")?.let(incidents::export) engineName=name; resetModeSettings(); updateCameraChoices(); restartCamera() } internal fun resetModeSettings() { depthSize = null + portraitConfig = null if (::callbackGraph.isInitialized) showCallbacks(false) pendingPip = null pipUi.resetMode() diff --git a/app/src/main/java/dev/halcamera/MainCameraPicker.kt b/app/src/main/java/dev/halcamera/MainCameraPicker.kt new file mode 100644 index 00000000..875268d3 --- /dev/null +++ b/app/src/main/java/dev/halcamera/MainCameraPicker.kt @@ -0,0 +1,53 @@ +package dev.halcamera + +import dev.halcamera.camera.CameraLabel +import dev.halcamera.camera.zoomPresets +import dev.halcamera.camera.zoomRange +import dev.halcamera.ui.LiveChoiceSheet + +/** Public camera selection leaves special sessions and resets lens-specific controls. */ +internal fun MainActivity.selectCamera() { + if (cli.active != null || recordingVideo || mediaBusy() || closing) return + val ids = cameraIds.sortedWith(CameraLabel.idOrder) + val choices = ids.map { id -> + val endpoint = cameraEndpoints[id] + val name = endpoint?.let { listOfNotNull(CameraLabel.facing(it.role, it.facing), CameraLabel.lens(it.role), + CameraLabel.angle(it.role, it.equivalentFocalMm)).joinToString(" · ") }?.takeIf { it.isNotEmpty() } ?: "Camera" + LiveChoiceSheet.Choice("Service · ID $id", name) + } + LiveChoiceSheet.show(this, "Camera", choices, ids.indexOf(cameraId)) { index -> + if (recordingVideo || !resumed || cli.active != null || mediaBusy() || closing) return@show + val chosen = ids[index] + if (cameraId != chosen) { + pendingPermissionAction = null + recorder.finish("camera_changed")?.let(::exportCliIncident) + cameraId = chosen; zoomRatio = 1f + depthSize = null; portraitConfig = null + resetControls(); updateCameraChoices(); restartCamera() + } + } +} + +internal fun MainActivity.cameraLabel(id: String): String = if (id.isEmpty()) "No camera" + else cameraEndpoints[id]?.let(CameraLabel::full) ?: CameraLabel.short(id) + +internal fun MainActivity.applyZoom(ratio: Float) { + if (depthSize != null || portraitConfig != null || !resumed || paused || closing || cli.active != null || bursts.controlsLocked || + !(ready || recordingVideo) || stoppingRecording || cameraId.isEmpty() || !ratio.isFinite()) return + val range = zoomRange(manager, cameraId) + zoomRatio = ratio.coerceIn(range.first, range.second) + engine?.setZoom(zoomRatio) + zoomControl.setRatio(zoomRatio) +} + +internal fun MainActivity.updateCameraChoices() { + if (cameraId.isNotEmpty()) { + val range = zoomRange(manager, cameraId) + zoomRatio = zoomRatio.coerceIn(range.first, range.second) + } + engineButton.text = engineName + engineButton.contentDescription = "Current: $engineName; tap to switch to ${if (engineName == "Camera2") "CameraX" else "Camera2"}" + cameraShortcut.contentDescription = "Choose camera, current: ${cameraLabel(cameraId)}" + cameraShortcut.tooltipText = cameraShortcut.contentDescription + zoomControl.setChoices(if (cameraId.isEmpty()) listOf(1f) else zoomPresets(zoomRange(manager, cameraId)), zoomRatio) +} diff --git a/app/src/main/java/dev/halcamera/MainCliBridge.kt b/app/src/main/java/dev/halcamera/MainCliBridge.kt index fde7bf3a..f50d0759 100644 --- a/app/src/main/java/dev/halcamera/MainCliBridge.kt +++ b/app/src/main/java/dev/halcamera/MainCliBridge.kt @@ -33,6 +33,7 @@ internal fun MainActivity.createLiveCli(): LiveController = LiveController(cli, } captureFeedback.clearResult() depthSize = null + portraitConfig = null showCallbacks(false) val modeChanged = engineName != (command.engine ?: "Camera2") || videoMode != command.videoMode cameraId = requireNotNull(command.camera); engineName = command.engine ?: "Camera2" @@ -72,7 +73,7 @@ internal fun MainActivity.createLiveCli(): LiveController = LiveController(cli, updateMediaControls() } override fun tune(options: dev.halcamera.cli.CliOptions?, reset: Boolean): Map { - if (depthSize != null) throw dev.halcamera.cli.CliFailure("PREFLIGHT_FAILED", "Select Photo or Video before changing camera controls.") + if (depthSize != null || portraitConfig != null) throw dev.halcamera.cli.CliFailure("PREFLIGHT_FAILED", "Select standard Photo or Video before changing camera controls.") if ((!ready && !recordingVideo) || closing || stoppingRecording) throw dev.halcamera.cli.CliFailure("PREFLIGHT_FAILED", "Start preview or recording first") val requested = if (reset) LiveControls() else options!!.controls(controlBar.support, manualCapabilities, videoMode, controlBar.controls, pipUi.selected != null) val zoom = if (reset) 1f else options!!.values["zoom"]?.toFloat() ?: zoomRatio diff --git a/app/src/main/java/dev/halcamera/MainDepthBridge.kt b/app/src/main/java/dev/halcamera/MainDepthBridge.kt index 1d8d7f6f..1ce3bb1f 100644 --- a/app/src/main/java/dev/halcamera/MainDepthBridge.kt +++ b/app/src/main/java/dev/halcamera/MainDepthBridge.kt @@ -4,7 +4,6 @@ import android.graphics.Typeface import android.hardware.camera2.CameraCharacteristics import android.view.View import android.widget.FrameLayout -import android.widget.Toast import androidx.core.view.ViewCompat import dev.halcamera.camera.* import dev.halcamera.ui.* @@ -13,18 +12,41 @@ import dev.halcamera.ui.* internal fun MainActivity.chooseDepth() { if (!resumed || closing || cli.active != null || recordingVideo || stoppingRecording || mediaBusy() || pendingPermissionAction != null || cameraId.isEmpty()) return - val support = try { depthSupport(manager.getCameraCharacteristics(cameraId)) } catch (e: Exception) { - Toast.makeText(this, "Cannot read depth capabilities: ${e.message}", Toast.LENGTH_LONG).show(); return + val id = cameraId + io.execute { + val portrait = portraitSupport(manager, id) + val depth = runCatching { depthSupport(manager.getCameraCharacteristics(id)) }.getOrNull() + main.post { + if (!resumed || closing || cameraId != id || cli.active != null || mediaBusy() || recordingVideo || + stoppingRecording || pendingPermissionAction != null) return@post + val choices = listOf(LiveChoiceSheet.Choice("Off", "Return to Photo"), + LiveChoiceSheet.Choice("Portrait (Bokeh)", portraitConfig?.let { "Active · JPEG ${it.jpeg} · Change resolution" } + ?: if (portrait.available) "Manufacturer background blur · Photos" else portrait.unavailableReason.orEmpty(), portrait.available), + LiveChoiceSheet.Choice("Depth Map", depthSize?.let { "Active · DEPTH16 $it · Change resolution" } + ?: if (depth?.available == true) "View sensor DEPTH16 · No photo capture" else depth?.unavailableReason ?: "Cannot read Depth capabilities.", depth?.available == true)) + LiveChoiceSheet.show(this, "Depth · Portrait", choices, + if (depthSize == null && portraitConfig == null && !videoMode) 0 else -1) { index -> + if (!resumed || closing || cameraId != id || cli.active != null || mediaBusy() || recordingVideo || + stoppingRecording || pendingPermissionAction != null) return@show + when (index) { + 0 -> selectMode(false) + 1 -> choosePortrait(portrait) + 2 -> depth?.let { chooseDepthMap(it) } + } + } + } } +} + +private fun MainActivity.chooseDepthMap(support: DepthSupport) { + val id = cameraId val sizes = support.sizes.takeIf { support.available }.orEmpty() - val choices = listOf(LiveChoiceSheet.Choice("Off", "Return to Photo")) + - sizes.map { LiveChoiceSheet.Choice("DEPTH16 · $it", "Camera2 · Depth-only view") } + - if (support.available) emptyList() else listOf(LiveChoiceSheet.Choice("Depth unavailable", support.unavailableReason.orEmpty(), false)) - LiveChoiceSheet.show(this, "Depth map", choices, if (videoMode) -1 else sizes.indexOf(depthSize) + 1, + val choices = sizes.map { LiveChoiceSheet.Choice("DEPTH16 · $it", "Camera2 · Depth-only view") } + LiveChoiceSheet.show(this, "Depth Map", choices, if (ready) sizes.indexOf(depthSize) else -1, content = Look.text(this, "${support.colorSummary}\nNear: bright · Far: dark · Black: invalid\nView only; photos and video are disabled.", 12, Look.onDarkMuted)) { index -> - if (!resumed || closing || cli.active != null || recordingVideo || mediaBusy()) return@show - val chosen = if (index == 0) null else sizes.getOrNull(index - 1) ?: return@show - if (chosen == depthSize && !videoMode) { return@show } + if (!resumed || closing || cameraId != id || cli.active != null || recordingVideo || stoppingRecording || + pendingPermissionAction != null || mediaBusy()) return@show + val chosen = sizes.getOrNull(index) ?: return@show recorder.finish("depth_view_changed")?.let(::exportCliIncident) captureFeedback.clearResult() engineName = "Camera2" @@ -50,7 +72,7 @@ internal fun MainActivity.createDepthEngine(released: String?, status: (String, return DepthCameraEngine(manager, cameraId, size, thisSession, telemetry, released, { frame -> if (thisSession == sessionId && resumed && !closing && !paused) { view.bind(frame) - depthFrameArrived() + previewFrameArrived() } }, status) } @@ -58,12 +80,13 @@ internal fun MainActivity.createDepthEngine(released: String?, status: (String, internal fun MainActivity.updateDepthControls(callbacks: Boolean, topInset: Int, bottomInset: Int) { depthModeButton.isEnabled = resumed && !closing && !recordingVideo && bursts.run == null && !stoppingRecording && !mediaBusy() && cli.active == null && pendingPermissionAction == null && cameraId.isNotEmpty() - depthModeButton.isSelected = depthSize != null - depthModeButton.setTextColor(if (depthSize != null) Look.onDark else Look.onDarkMuted) - depthModeButton.setTypeface(null, if (depthSize != null) Typeface.BOLD else Typeface.NORMAL) + val selected = depthSize != null || portraitConfig != null + depthModeButton.isSelected = selected + depthModeButton.setTextColor(if (selected) Look.onDark else Look.onDarkMuted) + depthModeButton.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) depthModeButton.alpha = if (depthModeButton.isEnabled) 1f else .4f - depthModeButton.contentDescription = "Depth map: choose a DEPTH16 resolution or view availability" - ViewCompat.setStateDescription(depthModeButton, if (depthSize != null) "Selected" else "Not selected") + depthModeButton.contentDescription = "Depth and Portrait: choose manufacturer Bokeh photos or a sensor Depth map" + ViewCompat.setStateDescription(depthModeButton, if (selected) "Selected" else "Not selected") if (depthSize != null) { mediaButton.isEnabled = false mediaButton.contentDescription = "Depth view only: select Photo or Video to capture media" @@ -82,7 +105,7 @@ internal fun MainActivity.updateDepthControls(callbacks: Boolean, topInset: Int, internal fun MainActivity.selectMode(video: Boolean) { if (cli.active != null) return if (recordingVideo || stoppingRecording || closing || pendingPermissionAction != null || - mediaBusy() || (depthSize == null && (!ready || videoMode==video))) return + mediaBusy() || (depthSize == null && portraitConfig == null && (!ready || videoMode==video))) return pendingPermissionAction=null videoMode=video resetModeSettings() diff --git a/app/src/main/java/dev/halcamera/MainPortraitBridge.kt b/app/src/main/java/dev/halcamera/MainPortraitBridge.kt new file mode 100644 index 00000000..d5a840ef --- /dev/null +++ b/app/src/main/java/dev/halcamera/MainPortraitBridge.kt @@ -0,0 +1,51 @@ +package dev.halcamera + +import android.os.Build +import android.view.TextureView +import android.view.View +import android.widget.FrameLayout +import dev.halcamera.camera.* +import dev.halcamera.ui.* + +/** The Depth menu opens this OEM resolution picker. */ +internal fun MainActivity.choosePortrait(support: PortraitSupport) { + if (!resumed || closing || cli.active != null || recordingVideo || stoppingRecording || mediaBusy() || + pendingPermissionAction != null || cameraId.isEmpty()) return + val configs = support.jpegs.mapNotNull(support::config) + val id = cameraId + val choices = configs.map { LiveChoiceSheet.Choice("JPEG ${it.jpeg}", "Manufacturer Bokeh · Preview ${it.preview}") } + LiveChoiceSheet.show(this, "Portrait · Manufacturer Bokeh", choices, if (ready) configs.indexOf(portraitConfig) else -1, + content = Look.text(this, "Choose a JPEG resolution. Photos only; RAW, PIP and manual controls are unavailable.", 12, Look.onDarkMuted)) { index -> + if (!resumed || closing || cameraId != id || cli.active != null || recordingVideo || stoppingRecording || + pendingPermissionAction != null || mediaBusy()) return@show + val chosen = configs.getOrNull(index) ?: return@show + recorder.finish("portrait_changed")?.let(::exportCliIncident) + captureFeedback.clearResult() + engineName = "Camera2" + videoMode = false + resetModeSettings() + portraitConfig = chosen + paused = false + updateCameraChoices(); restartCamera() + } +} + +internal fun MainActivity.createPortraitEngine(released: String?, status: (String, Boolean) -> Unit): CameraEngine? { + if (Build.VERSION.SDK_INT < 31) { setStatus("Portrait requires Android 12 or newer.", false); return null } + val thisSession = sessionId + val view = TextureView(this) + previewHost.addView(view, FrameLayout.LayoutParams(-1, -1)) + return PortraitCameraEngine(this, view, cameraId, requireNotNull(portraitConfig), sessionId, telemetry, released, + { if (thisSession == sessionId && resumed && !closing && !paused) previewFrameArrived() }, status) +} + +internal fun MainActivity.updatePortraitControls() { + if (portraitConfig == null) return + liveIndicator.bindStabilization(LiveEisStatus(0), false) + physicalPipButton.isEnabled = false; physicalPipButton.alpha = .4f + zoomControl.isEnabled = false + controlBar.bind(false, false, false) + manualPanel.close() + metrics.visibility = View.GONE + mediaButton.contentDescription = if (mediaBusy()) "Saving Portrait photo" else "Take a manufacturer Portrait photo" +} diff --git a/app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt b/app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt index 5da08af7..621409ea 100644 --- a/app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt +++ b/app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt @@ -88,6 +88,7 @@ class CameraProbeReader(private val manager: CameraManager, private val extraDev sections += guarded("Identity") { identity(id, physicalOf, c) } sections += guarded("Capabilities") { capabilities(c) } sections += guarded("Depth") { depthSupport(c).probeRows(physicalOf == null) } + sections += guarded("Portrait · Manufacturer Bokeh") { portraitSupport(manager, id, physicalOf == null).probeRows() } sections += guarded("Sensor") { sensor(c) } sections += guarded("Lens") { lens(c) } sections += guarded("Control") { control(c) } diff --git a/app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt b/app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt new file mode 100644 index 00000000..48794344 --- /dev/null +++ b/app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt @@ -0,0 +1,267 @@ +package dev.halcamera.camera + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.ImageFormat +import android.graphics.SurfaceTexture +import android.hardware.camera2.* +import android.hardware.camera2.params.ExtensionSessionConfiguration +import android.hardware.camera2.params.OutputConfiguration +import android.media.ImageReader +import android.os.* +import android.view.Surface +import android.view.TextureView +import androidx.annotation.RequiresApi +import dev.halcamera.telemetry.Telemetry +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** LIVE-only OEM Bokeh session: one preview, one JPEG, no benchmark or manual-control contract. */ +@RequiresApi(31) +class PortraitCameraEngine( + context: Context, + private val view: TextureView, + private val cameraId: String, + private val config: PortraitConfig, + private val sessionId: String, + private val telemetry: Telemetry, + private val releasedCameraId: String?, + private val frame: () -> Unit, + private val status: (String, Boolean) -> Unit, +) : CameraEngine, MediaCapture { + private val manager = context.getSystemService(CameraManager::class.java) + private val library = MediaLibrary(context) + private val thread = HandlerThread("HAL-portrait").apply { start() } + private val handler = Handler(thread.looper) + private val main = Handler(Looper.getMainLooper()) + private val executor = Executor { handler.post(it) } + private val io = Executors.newSingleThreadExecutor() + private val busy = AtomicBoolean(false) + override val mediaBusy get() = busy.get() + @Volatile private var active = true + @Volatile private var firstFrame = false + private var opening = false + private var finished = false + private var device: CameraDevice? = null + private var session: CameraExtensionSession? = null + private var reader: ImageReader? = null + private var surface: Surface? = null + private var availability: CameraManager.AvailabilityCallback? = null + private val closers = mutableListOf<() -> Unit>() + private class Photo(val id: String, val done: (Result) -> Unit) { + val delivered = AtomicBoolean(false) + } + private var pending: Photo? = null + private val openAfterRelease = Runnable { stopWaiting(); open() } + private val firstTimeout = Runnable { if (active && !firstFrame) fail("No Portrait preview frames within 15 seconds.") } + private val captureTimeout = Runnable { if (pending != null) fail("Portrait capture timed out after 60 seconds. Select Photo to retry.") } + + override fun start() { + main.post { + if (!active) return@post + view.surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) = prepare(texture) + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) = fit() + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { + if (!active) return + firstFrame = true + frame() + } + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + handler.post { if (active) fail("Portrait preview surface closed.") } + return true + } + } + view.surfaceTexture?.let(::prepare) + } + } + + private fun fit() = view.fitPreview(android.util.Size(config.preview.width, config.preview.height)) + private fun prepare(texture: SurfaceTexture) { + texture.setDefaultBufferSize(config.preview.width, config.preview.height) + fit() + handler.post { + if (!active || surface != null) return@post + try { + val support = portraitSupport(manager, cameraId) + require(support.available && config.preview in support.previews && config.jpeg in support.jpegs) { + support.unavailableReason ?: "Unsupported Portrait resolutions." + } + surface = Surface(texture) + telemetry.registerSession(sessionId, "Camera2 · Portrait", manager, cameraId) + if (releasedCameraId != null && releasedCameraId in manager.cameraIdList) { + availability = object : CameraManager.AvailabilityCallback() { + override fun onCameraAvailable(id: String) { + if (id == releasedCameraId && availability != null) openAfterRelease.run() + } + }.also { manager.registerAvailabilityCallback(it, handler) } + handler.postDelayed(openAfterRelease, CameraReleaseWait.LIMIT_MS) + } else open() + } catch (e: Exception) { fail(e.message ?: "Portrait unavailable") } + } + } + + @SuppressLint("MissingPermission") + private fun open() { + if (!active || opening || device != null) return + try { + opening = true + telemetry.event(sessionId, "open_call") + manager.openCamera(cameraId, object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + opening = false; device = camera + if (!active) camera.close() else configure(camera) + } + override fun onDisconnected(camera: CameraDevice) = lost(camera, "Portrait camera disconnected") + override fun onError(camera: CameraDevice, error: Int) = lost(camera, "Portrait camera error $error") + private fun lost(camera: CameraDevice, reason: String) { + opening = false; device = camera + if (active) fail(reason) else camera.close() + } + override fun onClosed(camera: CameraDevice) { + if (device === camera) device = null + finishClose() + } + }, handler) + } catch (e: Exception) { opening = false; fail("Cannot open Portrait camera: ${e.message}") } + } + + private fun configure(camera: CameraDevice) { + try { + val output = ImageReader.newInstance(config.jpeg.width, config.jpeg.height, ImageFormat.JPEG, 2) + reader = output + output.setOnImageAvailableListener({ source -> + try { + source.acquireNextImage()?.use { image -> + val photo = pending ?: return@use + if (!active) return@use + val buffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + val timestamp = image.timestamp + telemetry.image(sessionId, timestamp, image.width, image.height, image.format, "portrait_jpeg") + pending = null + handler.removeCallbacks(captureTimeout) + io.execute { + val result = runCatching { + val files = library.saveCapture(library.name(), null, bytes, mapOf( + "requestId" to photo.id, "cameraId" to cameraId, "engine" to "Camera2", + "effect" to "manufacturer_bokeh", "jpegSource" to "OEM_BOKEH", + "previewSize" to config.preview.toString(), "jpegSize" to config.jpeg.toString(), + "sensorTimestamp" to timestamp, "correlation" to "One extension capture in flight; OEM processed JPEG")) + PhotoResult(photo.id, files.first().name, timestamp, files.map { it.uri }, files) + } + deliver(photo, result) + main.post { if (active) status(result.fold({ "Saved Portrait JPEG" }, { "Portrait save failed: ${it.message}" }), true) } + } + } + } catch (e: Exception) { if (active) fail("Portrait JPEG failed: ${e.message}") } + }, handler) + val outputs = listOf(OutputConfiguration(requireNotNull(surface)), OutputConfiguration(output.surface)) + camera.createExtensionSession(ExtensionSessionConfiguration(CameraExtensionCharacteristics.EXTENSION_BOKEH, outputs, executor, + object : CameraExtensionSession.StateCallback() { + override fun onConfigured(value: CameraExtensionSession) { + if (!active || device !== camera) { value.close(); return } + session = value + try { + val request = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { + addTarget(requireNotNull(surface)); setTag("portrait_preview") + }.build() + value.setRepeatingRequest(request, executor, object : CameraExtensionSession.ExtensionCaptureCallback() { + override fun onCaptureFailed(s: CameraExtensionSession, r: CaptureRequest) { + if (active) fail("The manufacturer rejected the Portrait preview.") + } + }) + telemetry.sessions.computeIfPresent(sessionId) { _, old -> old + mapOf("negotiatedStreams" to mapOf( + "preview" to config.preview.toString(), "jpeg" to config.jpeg.toString(), "portrait" to "Manufacturer Bokeh")) } + main.post { if (active) status("Portrait · Manufacturer Bokeh · LIVE", true) } + handler.postDelayed(firstTimeout, 15000) + } catch (e: Exception) { fail("Portrait preview failed: ${e.message}") } + } + override fun onConfigureFailed(value: CameraExtensionSession) { + runCatching { value.close() } + if (active) fail("The manufacturer rejected Portrait at P ${config.preview} / JPEG ${config.jpeg}. Select another resolution or Photo.") + } + })) + } catch (e: Exception) { fail("Portrait session failed: ${e.message}") } + } + + override fun capture() = capturePhoto(UUID.randomUUID().toString()) { } + override fun capturePhoto(requestId: String, done: (Result) -> Unit) { + if (!active || !firstFrame || !busy.compareAndSet(false, true)) { + main.post { done(Result.failure(IllegalStateException("Portrait camera not ready or busy"))) }; return + } + @Suppress("DEPRECATION") + val displayRotation = view.display?.rotation ?: Surface.ROTATION_0 + val photo = Photo(requestId, done) + if (!handler.post { + pending = photo + try { + check(active) { "Portrait camera closed" } + val camera = requireNotNull(device) + val chars = manager.getCameraCharacteristics(cameraId) + val degrees = displayRotation * 90 + val sensor = chars[CameraCharacteristics.SENSOR_ORIENTATION] ?: 0 + val front = chars[CameraCharacteristics.LENS_FACING] == CameraCharacteristics.LENS_FACING_FRONT + val orientation = (sensor + if (front) degrees else -degrees + 360) % 360 + val request = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { + addTarget(requireNotNull(reader).surface); setTag(photo.id) + set(CaptureRequest.JPEG_ORIENTATION, orientation) + }.build() + telemetry.event(sessionId, "portrait_capture_submit", mapOf("requestId" to photo.id, "jpegOrientation" to orientation)) + main.post { if (active) status("Capturing Portrait…", false) } + handler.postDelayed(captureTimeout, 60000) + requireNotNull(session).capture(request, executor, object : CameraExtensionSession.ExtensionCaptureCallback() { + override fun onCaptureStarted(s: CameraExtensionSession, r: CaptureRequest, timestamp: Long) { + if (active) telemetry.event(sessionId, "portrait_capture_started", mapOf("requestId" to photo.id, "sensorTimestamp" to timestamp)) + } + override fun onCaptureFailed(s: CameraExtensionSession, r: CaptureRequest) { + if (pending === photo) fail("Manufacturer Portrait capture failed. Select Photo to retry.") + } + override fun onCaptureSequenceAborted(s: CameraExtensionSession, sequenceId: Int) { + if (pending === photo) fail("Manufacturer Portrait capture aborted. Select Photo to retry.") + } + }) + } catch (e: Exception) { fail("Portrait capture failed: ${e.message}") } + }) deliver(photo, Result.failure(IllegalStateException("Portrait camera closed"))) + } + + private fun deliver(photo: Photo, result: Result) { + if (photo.delivered.compareAndSet(false, true)) main.post { busy.set(false); photo.done(result) } + } + override fun setZoom(ratio: Float) = Unit + override fun startRecording(audio: Boolean, started: () -> Unit, done: ((Result) -> Unit)?) { + main.post { done?.invoke(Result.failure(IllegalStateException("Manufacturer Portrait supports photos only."))) } + } + override fun stopRecording() = Unit + override fun close(done: () -> Unit) { + active = false + if (!handler.post { if (finished) main.post(done) else { closers += done; release() } }) main.post(done) + } + private fun fail(reason: String) { + active = false + telemetry.event(sessionId, "portrait_failed", mapOf("reason" to reason)) + main.post { status(reason, false) } + release() + } + private fun stopWaiting() { + handler.removeCallbacks(openAfterRelease) + availability?.let { manager.unregisterAvailabilityCallback(it) }; availability = null + } + private fun release() { + stopWaiting(); handler.removeCallbacks(firstTimeout); handler.removeCallbacks(captureTimeout) + pending?.let { deliver(it, Result.failure(IllegalStateException("Portrait closed before JPEG arrived"))) }; pending = null + runCatching { session?.close() }; session = null + if (device != null) device?.close() else if (!opening) finishClose() + } + private fun finishClose() { + if (device != null || opening || finished) return + finished = true + reader?.close(); reader = null + surface?.release(); surface = null + val callbacks = closers.toList().also { closers.clear() } + main.post { view.surfaceTextureListener = null; callbacks.forEach { it() } } + io.shutdown(); thread.quitSafely() + } +} diff --git a/app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt b/app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt new file mode 100644 index 00000000..7c843f44 --- /dev/null +++ b/app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt @@ -0,0 +1,26 @@ +package dev.halcamera.camera + +import android.graphics.ImageFormat +import android.graphics.SurfaceTexture +import android.hardware.camera2.CameraExtensionCharacteristics +import android.hardware.camera2.CameraManager +import android.os.Build + +fun portraitSupport(manager: CameraManager, id: String, publicCamera: Boolean = true): PortraitSupport { + if (!publicCamera) return PortraitSupport(null, reason = "Physical camera: select a publicly listed camera ID in LIVE") + if (Build.VERSION.SDK_INT < 31) return PortraitSupport(null, reason = "Camera2 Portrait requires Android 12 / API 31 or newer.") + val errors = mutableListOf() + fun read(label: String, block: () -> T): T? = try { block() } catch (e: Exception) { + errors += "$label: ${e.javaClass.simpleName}: ${e.message}"; null + } + val extension = read("Bokeh characteristics") { manager.getCameraExtensionCharacteristics(id) } + val mode = CameraExtensionCharacteristics.EXTENSION_BOKEH + val advertised = read("Supported extensions") { extension?.supportedExtensions?.contains(mode) } + fun sizes(label: String, block: () -> List?): List = + if (advertised != true) emptyList() else read(label) { + block().orEmpty().map { LiveSize(it.width, it.height) }.distinct().sortedBy { it.width.toLong() * it.height } + }.orEmpty() + return PortraitSupport(advertised, + sizes("Bokeh preview sizes") { extension?.getExtensionSupportedSizes(mode, SurfaceTexture::class.java) }, + sizes("Bokeh JPEG sizes") { extension?.getExtensionSupportedSizes(mode, ImageFormat.JPEG) }, errors = errors) +} diff --git a/app/src/main/java/dev/halcamera/camera/PortraitSupport.kt b/app/src/main/java/dev/halcamera/camera/PortraitSupport.kt new file mode 100644 index 00000000..d2b678cc --- /dev/null +++ b/app/src/main/java/dev/halcamera/camera/PortraitSupport.kt @@ -0,0 +1,40 @@ +package dev.halcamera.camera + +import java.io.Serializable +import kotlin.math.abs + +data class PortraitConfig(val preview: LiveSize, val jpeg: LiveSize) : Serializable + +/** Manufacturer declarations; a listed size pair still needs session validation. */ +data class PortraitSupport( + val advertised: Boolean?, + val previews: List = emptyList(), + val jpegs: List = emptyList(), + val reason: String? = null, + val errors: List = emptyList(), +) { + val available get() = reason == null && advertised == true && previews.isNotEmpty() && jpegs.isNotEmpty() + val unavailableReason get() = reason ?: when { + advertised == null -> "Manufacturer Bokeh capability could not be read." + !advertised -> "This camera does not advertise the BOKEH extension." + previews.isEmpty() -> "No readable Bokeh preview resolutions." + jpegs.isEmpty() -> "No readable Bokeh JPEG resolutions." + else -> null + } + + /** A visible initial pairing, never a retry fallback. Prefer matching aspect and a ~1080p preview. */ + fun config(jpeg: LiveSize): PortraitConfig? { + if (!available || jpeg !in jpegs) return null + val ratio = jpeg.width.toDouble() / jpeg.height + val preview = previews.minWithOrNull(compareBy { abs(it.width.toDouble() / it.height - ratio) } + .thenBy { abs(it.width.toLong() * it.height - 1920L * 1080) }) ?: return null + return PortraitConfig(preview, jpeg) + } + + fun probeRows(): List = listOf( + ProbeRow("Manufacturer BOKEH", when (advertised) { true -> "Supported"; false -> "Not supported"; null -> "Unknown" }), + ProbeRow("Bokeh preview resolutions", previews.joinToString().ifEmpty { "None reported" }), + ProbeRow("Bokeh JPEG resolutions", jpegs.joinToString().ifEmpty { "None reported" }), + ProbeRow("LIVE Portrait", if (available) "Photo only; size combination must still be checked" else unavailableReason.orEmpty()), + ) + errors.map { ProbeRow("Read error", it) } +} diff --git a/app/src/main/java/dev/halcamera/ui/LiveIndicator.kt b/app/src/main/java/dev/halcamera/ui/LiveIndicator.kt index 1ea58d78..218318c3 100644 --- a/app/src/main/java/dev/halcamera/ui/LiveIndicator.kt +++ b/app/src/main/java/dev/halcamera/ui/LiveIndicator.kt @@ -98,7 +98,8 @@ class LiveIndicator(context: Context) : LinearLayout(context) { eis.setTextColor(if (status.warning != null) Look.statusWarn else Look.onDarkMuted) } - fun bindSizes(streams: Map<*, *>?) = bindSizes(LiveStreamReadout.from(streams)) + fun bindSizes(streams: Map<*, *>?) = bindSizes(LiveStreamReadout.from(streams), + if (streams?.get("portrait") != null) "open Depth and Portrait" else "open Live Streams") internal fun bindSizes(value: LiveStreamReadout, action: String = "open Live Streams", selectedLine: Int? = null) { sizes.maxLines = maxOf(2,value.text.count { it == '\n' }+1) diff --git a/app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt b/app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt index 976ee52a..96cebd17 100644 --- a/app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt +++ b/app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt @@ -25,7 +25,9 @@ internal data class LiveStreamReadout(val text: String, val description: String) val description = active.joinToString(", ") { (label, size) -> "${label.name}${if (label.key == "recording") " $format" else ""} $size" } - return LiveStreamReadout(text, description) + val portrait = value("portrait") + return LiveStreamReadout(if (portrait != null) "Portrait · $text" else text, + if (portrait != null) "$portrait, $description" else description) } /** Multi: one line per camera, numbered like its Split name bar. */ diff --git a/app/src/test/java/dev/halcamera/camera/PortraitSupportTest.kt b/app/src/test/java/dev/halcamera/camera/PortraitSupportTest.kt new file mode 100644 index 00000000..595fedc6 --- /dev/null +++ b/app/src/test/java/dev/halcamera/camera/PortraitSupportTest.kt @@ -0,0 +1,42 @@ +package dev.halcamera.camera + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class PortraitSupportTest { + private val preview = LiveSize(1440, 1080) + private val jpeg = LiveSize(4000, 3000) + + @Test fun `declared support also requires both extension output types`() { + assertFalse(PortraitSupport(null, listOf(preview), listOf(jpeg)).available) + assertFalse(PortraitSupport(false, listOf(preview), listOf(jpeg)).available) + assertFalse(PortraitSupport(true, emptyList(), listOf(jpeg)).available) + assertFalse(PortraitSupport(true, listOf(preview)).available) + assertFalse(PortraitSupport(true, listOf(preview), listOf(jpeg), "Not a public camera").available) + assertTrue(PortraitSupport(true, listOf(preview), listOf(jpeg)).available) + } + + @Test fun `initial pair matches aspect before closeness to preview target and never invents sizes`() { + val wide = LiveSize(1920, 1080) + val support = PortraitSupport(true, listOf(wide, preview, LiveSize(640, 480)), listOf(jpeg)) + assertEquals(PortraitConfig(preview, jpeg), support.config(jpeg)) + assertNull(support.config(wide)) + assertNull(support.copy(advertised = false).config(jpeg)) + assertEquals(preview, support.copy(previews = listOf(preview)).config(jpeg)?.preview) + } + + @Test fun `exports preserve unknown errors and extension resolutions without claiming verified combinations`() { + val support = PortraitSupport(true, listOf(preview), listOf(jpeg), errors = listOf("vendor metadata incomplete")) + val snapshot = CameraProbeSnapshot("now", emptyList(), listOf(CameraProbeEntry("0", null, "Camera 0", + listOf(ProbeSection("Portrait", support.probeRows())))), emptyList()) + val text = CameraProbeText.render(snapshot) + assertTrue(text.contains("1440x1080")); assertTrue(text.contains("4000x3000")) + assertTrue(text.contains("combination must still be checked")) + assertTrue(snapshot.toJsonMap().toString().contains("vendor metadata incomplete")) + assertEquals("Unknown", PortraitSupport(null).probeRows().first().value) + assertTrue(PortraitSupport(true, listOf(preview)).unavailableReason!!.contains("JPEG")) + } +} diff --git a/app/src/test/java/dev/halcamera/ui/LiveStreamReadoutTest.kt b/app/src/test/java/dev/halcamera/ui/LiveStreamReadoutTest.kt index 643e1cdf..95033455 100644 --- a/app/src/test/java/dev/halcamera/ui/LiveStreamReadoutTest.kt +++ b/app/src/test/java/dev/halcamera/ui/LiveStreamReadoutTest.kt @@ -4,6 +4,11 @@ import org.junit.Assert.* import org.junit.Test class LiveStreamReadoutTest { + @Test fun portraitNamesTheManufacturerEffectAndItsActualStreams() { + val readout = LiveStreamReadout.from(mapOf("portrait" to "Manufacturer Bokeh", "preview" to "1440x1080", "jpeg" to "4000x3000")) + assertEquals("Portrait · P 1440×1080 J 4000×3000", readout.text) + assertEquals("Manufacturer Bokeh, Preview 1440x1080, JPEG 4000x3000", readout.description) + } @Test fun depthOnlyDoesNotInventColorOutputs() { assertEquals(LiveStreamReadout("Depth 320×240", "Depth 320x240"), LiveStreamReadout.from(mapOf("depth" to "320x240"))) diff --git a/docs/.site-manifest.json b/docs/.site-manifest.json index 97ff5d45..92e4193d 100644 --- a/docs/.site-manifest.json +++ b/docs/.site-manifest.json @@ -4,7 +4,7 @@ ".nojekyll": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "_inputs/decisions.html": "aadc3cad5fb3b8e37af148aa62ae79f9be0ba19a076fd2837f85c471b1629ded", "_inputs/device-verification.yaml": "1852f48d52f75733b4db973418fc7fad1f0f9d340a8b4b555cf9d49aa7016215", - "architecture.html": "c25e2ce64ddb1b47c6c15aabc52c7742a2aed3fb830f72bbbf353d0450624361", + "architecture.html": "2e724bfe40ab9f43def45e11f6a079574f798dbf974875ebcf591d1dfcc0c1ab", "assets/docflow-design.css": "1cc4f34d8df94abdcf70ab2e9c51e456d8ef7f52283a2ef3adb986a6bd38d44c", "assets/media/automation/bridge.png": "bdbd7cbdc28010caa9b8e320e2d311efd35bb2f66cbb9b5cb322af638b7ccbff", "assets/media/automation/opening-sensor.mp4": "8e26f5eeeb0b92cd241b2235aab7ffd122cbf2ac94b66c37fafb440da4ae1178", @@ -72,8 +72,8 @@ "getting-started.html": "75b356487a9be6930c095686fcdc47229ecdd53b73b77f971a770f1e0f387e0f", "glossary.html": "a26775e3f8a0d6af19b7531b2dc86ea7630a9c0511bb34a308e3a630522462f1", "index.html": "34466a88605267322b92082ec1592c72a48a9fd2e3cdee59e66cea770d79372a", - "live.html": "5a5c7a1bb6ba91857d68ccbbce07e28894c6c6194191c227a91241460686f119", - "probe.html": "95c89f7937f412abe452c3a8a3134b1f129908d8bccebb78671d1c49fd0302a1", + "live.html": "7b43bcd9f7eaaf71491a9555d58e54fb037f785481720ac92512b264cc39e971", + "probe.html": "1a6eca6e50a2a21ede1c88bd122a000ca4be349733e4866ab6870b676f76447a", "troubleshooting.html": "819baee906880fbaab14d0a649623326227868e748966ddbfe2d0272a50e53cb" } } diff --git a/docs/architecture.html b/docs/architecture.html index 22138c8e..3fa2ac2a 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -79,7 +79,7 @@

One system.
Clear responsibilities.

측정·판 Live · MainActivity -앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며 지원 카메라의 Depth 맵을 봅니다. +앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며, 지원 카메라의 제조사 Portrait와 Depth 맵을 선택합니다. Multi · ConcurrentCameraActivity @@ -113,7 +113,7 @@

코드를 처음 읽는 순서

근거와 검토 정보
  • 근거 파일: app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, tools/halcam/halcam/cli.py, app/src/main/java/dev/halcamera/MainActivity.kt, app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt, app/src/main/java/dev/halcamera/WorkbenchActivity.kt, app/src/main/java/dev/halcamera/camera/CameraEngine.kt, app/src/main/java/dev/halcamera/telemetry/Telemetry.kt, app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt, app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt, app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt
  • 근거 수준: 코드 확인
  • -
  • 검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review
  • +
  • 검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review

전체 구조도

화살표는 코드를 사용하는 방향입니다. 이벤트와 파일이 이동하는 순서는 데이터 흐름에서 확인하세요.

@@ -210,6 +210,8 @@

코드를 처음 읽는 순서

camera/
  • 엔진 계약, Camera2·CameraX 구현, 엔드포인트 열거를 제공합니다. DepthCameraEngine은 컬러 출력 없이 DEPTH16 맵만 받으며 DepthFrame이 순수 Kotlin으로 표시용 표본을 만듭니다.

  • +
  • PortraitCameraEngine은 LIVE의 제조사 BOKEH 전용 세션입니다. MainPortraitBridge가 JPEG 크기를 선택하고 MainDepthBridge가 Depth Map과 Portrait의 진입점을 구분합니다. 이 경로는 벤치마크를 실행하거나 센서 Depth 맵을 저장하지 않습니다.

    +
  • close(done) 완료 전에 다음 카메라를 열지 않습니다.

  • 엔진 계약(MediaCapture, LiveTuning, TouchMetering)과 두 엔진의 구현, 두 엔진의 차이는 Engine Comparison에 있습니다.

    @@ -290,9 +292,9 @@

    코드를 처음 읽는 순서

    ui/RecentMediaButton은 받은 썸네일을 원형으로 그리며, 항목이 동영상이면 가운데에 재생 표시를 겹쳐 접근성 설명뿐 아니라 화면으로도 종류를 알 수 있게 합니다.

    근거와 검토 정보
      -
    • 근거 파일: app/src/main/java/dev/halcamera/camera/MultiSession.kt, app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt, app/src/main/java/dev/halcamera/camera/MultiRecording.kt, app/src/main/java/dev/halcamera/camera/SplitDragGesture.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, app/src/main/java/dev/halcamera/cli/LiveController.kt, app/src/main/java/dev/halcamera/cli/BenchmarkController.kt, app/src/main/java/dev/halcamera/cli/CliLibrary.kt, app/src/main/java/dev/halcamera/MainCliBridge.kt, app/src/main/java/dev/halcamera/DualCliBridge.kt, app/src/main/java/dev/halcamera/camera/CameraEngine.kt, app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt, app/src/main/java/dev/halcamera/benchmark/platform/EnvironmentProbe.kt, app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt, app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunIndex.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkCsv.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt, app/src/main/java/dev/halcamera/MainActivity.kt, app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt, app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt, app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt, app/src/main/java/dev/halcamera/ui/RecentMediaButton.kt, app/src/main/java/dev/halcamera/ui/Look.kt, app/src/main/java/dev/halcamera/cts/recording/BasicRecordingRules.kt, app/src/main/java/dev/halcamera/cts/CtsEntryActivity.kt, app/src/main/java/dev/halcamera/cts/vendored/VendoredCaseActivity.kt, app/src/main/java/dev/halcamera/cts/vendored/VendoredCtsListActivity.kt, app/src/main/java/dev/halcamera/cts/CtsCaseActivity.kt, app/src/main/java/dev/halcamera/cts/CtsCaseListActivity.kt, app/src/main/java/dev/halcamera/cts/suite/CtsChecklistActivity.kt, app/src/main/java/dev/halcamera/cts/suite/CtsSuiteRunActivity.kt, app/src/main/java/dev/halcamera/cts/suite/SuitePlan.kt, app/src/main/java/dev/halcamera/cts/suite/SuiteReport.kt, app/src/main/java/dev/halcamera/cts/CtsCatalog.kt, app/src/main/java/dev/halcamera/cts/CtsRunner.kt, app/src/main/java/dev/halcamera/cts/CameraCaseRunner.kt, app/src/main/java/dev/halcamera/cts/Camera2Ops.kt, app/src/main/java/dev/halcamera/cts/onoff/FastOnOffRules.kt, app/src/main/java/dev/halcamera/cts/switching/SwitchingRules.kt, app/src/main/java/dev/halcamera/cts/sizes/AllSizeOnOffRules.kt, app/src/main/java/dev/halcamera/cts/combination/StillPreviewCombinationRules.kt, app/src/main/java/dev/halcamera/cts/snapshot/VideoSnapshotRules.kt, app/src/main/java/dev/halcamera/CameraProbeActivity.kt, app/src/main/java/dev/halcamera/camera/CameraProbe.kt, app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt
    • +
    • 근거 파일: app/src/main/java/dev/halcamera/camera/MultiSession.kt, app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt, app/src/main/java/dev/halcamera/camera/MultiRecording.kt, app/src/main/java/dev/halcamera/camera/SplitDragGesture.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, app/src/main/java/dev/halcamera/cli/LiveController.kt, app/src/main/java/dev/halcamera/cli/BenchmarkController.kt, app/src/main/java/dev/halcamera/cli/CliLibrary.kt, app/src/main/java/dev/halcamera/MainCliBridge.kt, app/src/main/java/dev/halcamera/DualCliBridge.kt, app/src/main/java/dev/halcamera/camera/CameraEngine.kt, app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt, app/src/main/java/dev/halcamera/benchmark/platform/EnvironmentProbe.kt, app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt, app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunIndex.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkCsv.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt, app/src/main/java/dev/halcamera/MainActivity.kt, app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt, app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt, app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt, app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt, app/src/main/java/dev/halcamera/ui/RecentMediaButton.kt, app/src/main/java/dev/halcamera/ui/Look.kt, app/src/main/java/dev/halcamera/cts/recording/BasicRecordingRules.kt, app/src/main/java/dev/halcamera/cts/CtsEntryActivity.kt, app/src/main/java/dev/halcamera/cts/vendored/VendoredCaseActivity.kt, app/src/main/java/dev/halcamera/cts/vendored/VendoredCtsListActivity.kt, app/src/main/java/dev/halcamera/cts/CtsCaseActivity.kt, app/src/main/java/dev/halcamera/cts/CtsCaseListActivity.kt, app/src/main/java/dev/halcamera/cts/suite/CtsChecklistActivity.kt, app/src/main/java/dev/halcamera/cts/suite/CtsSuiteRunActivity.kt, app/src/main/java/dev/halcamera/cts/suite/SuitePlan.kt, app/src/main/java/dev/halcamera/cts/suite/SuiteReport.kt, app/src/main/java/dev/halcamera/cts/CtsCatalog.kt, app/src/main/java/dev/halcamera/cts/CtsRunner.kt, app/src/main/java/dev/halcamera/cts/CameraCaseRunner.kt, app/src/main/java/dev/halcamera/cts/Camera2Ops.kt, app/src/main/java/dev/halcamera/cts/onoff/FastOnOffRules.kt, app/src/main/java/dev/halcamera/cts/switching/SwitchingRules.kt, app/src/main/java/dev/halcamera/cts/sizes/AllSizeOnOffRules.kt, app/src/main/java/dev/halcamera/cts/combination/StillPreviewCombinationRules.kt, app/src/main/java/dev/halcamera/cts/snapshot/VideoSnapshotRules.kt, app/src/main/java/dev/halcamera/CameraProbeActivity.kt, app/src/main/java/dev/halcamera/camera/CameraProbe.kt, app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt
    • 근거 수준: 코드 확인
    • -
    • 검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review
    • +
    • 검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review

    CLI 책임을 따라가세요

    CliCommands는 명령별 필수 인자·허용 옵션·기본 제한·실행 대상과 Live에서 실행되는 방식(flow)·녹화 여부를 한곳에서 정의하고 hello.command_specs로 제공합니다.

    @@ -310,6 +312,7 @@

    주요 실행 흐름

    배치·간격·애니메이션·접근성 문구 같은 앱의 조작 규칙은 APP-UI.md가 관리합니다.

    Live 셔터 조작은 MainActivity에서 선택한 엔진의 촬영·녹화 동작으로 이어집니다.

    Live의 Depth 보기는 DepthCameraEngine → ImageReader → DepthFrame → DepthMapView로 연결합니다. 컬러 촬영과 분리된 보기이며, Telemetry에는 버퍼의 시각·크기·형식과 capture 콜백만 기록합니다. 맵 픽셀을 벤치마크나 incident ZIP의 입력으로 전달하지 않습니다. 사용 순서는 Depth 맵 보기에 있습니다.

    +

    같은 Depth 메뉴의 Portrait는 PortraitCameraEngine → CameraExtensionSession → JPEG ImageReader → MediaLibrary로 연결합니다. 제조사 Bokeh가 적용된 컬러 사진을 저장하며 별도의 센서 Depth 맵을 받지 않습니다. 확장 처리 이벤트는 일반 Camera2 프레임 지표와 구분하고, 사진 픽셀은 측정 파일에 넣지 않습니다.

    벤치마크는 별도 화면인 BenchmarkActivity가 준비를 마친 뒤 BenchmarkRunner.start()를 호출하여 시작합니다.

    Telemetry.callback(...)이 반환한 Camera2 콜백의 onCaptureStarted는 프레임워크 신호를 기록하며, Live 셔터와 벤치마크 시작을 연결하는 메서드가 아닙니다.

    실행에서 저장까지

    @@ -436,9 +439,9 @@

    CLI 요청과 결과 수집

    이전 CameraX 그래프가 닫히기 전에 MainActivity가 다시 카메라를 열지 않도록 합니다.

    근거와 검토 정보
      -
    • 근거 파일: app/src/main/java/dev/halcamera/cli/CliProvider.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, tools/halcam/halcam/cli.py, tools/halcam/halcam/download.py, app/src/main/java/dev/halcamera/MainActivity.kt, app/src/main/java/dev/halcamera/GalleryActivity.kt, app/src/main/java/dev/halcamera/camera/MediaLibrary.kt, app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt, app/src/main/java/dev/halcamera/telemetry/Telemetry.kt, app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt, app/src/main/java/dev/halcamera/metrics/MetricExtractor.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunRetention.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt, app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt, app/src/main/java/dev/halcamera/benchmark/platform/LaunchDiagnostics.kt, app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt
    • +
    • 근거 파일: app/src/main/java/dev/halcamera/cli/CliProvider.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, tools/halcam/halcam/cli.py, tools/halcam/halcam/download.py, app/src/main/java/dev/halcamera/MainActivity.kt, app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt, app/src/main/java/dev/halcamera/GalleryActivity.kt, app/src/main/java/dev/halcamera/camera/MediaLibrary.kt, app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt, app/src/main/java/dev/halcamera/telemetry/Telemetry.kt, app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt, app/src/main/java/dev/halcamera/metrics/MetricExtractor.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunRetention.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt, app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt, app/src/main/java/dev/halcamera/benchmark/platform/LaunchDiagnostics.kt, app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt
    • 근거 수준: 코드 확인
    • -
    • 검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review
    • +
    • 검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review

    이벤트와 결과가 전달되는 경로

    graph TB
    @@ -576,7 +579,7 @@ 

    카메라 이름을 적는 방법

    줌 컨트롤의 상태

    현재 배율을 누르면 지원 배율이 펼쳐집니다. 처음에는 현재 배율 하나만 보입니다.

    Live 프리뷰에서 두 손가락을 벌리거나 모으면 지원 범위 안에서 연속으로 줌을 바꿉니다. 가장 가까운 프리셋의 원에 현재 배율을 소수 첫째 자리까지 표시하며, 펼친 상태에서 그 원을 누르면 해당 프리셋 배율로 돌아갑니다. 화면의 카메라 선택 정보를 갱신해도 중간 배율은 유지합니다.

    -

    핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다.

    +

    핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기·제조사 Portrait, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다.

    @@ -632,6 +635,7 @@

    실행과 측정의 제약

    Depth 보기는 컬러와 Depth를 같은 요청에 포함하지 않는 별도 Camera2 세션입니다. HAL의 동시 요청 허용 선언은 특정 출력 조합의 성공을 보장하지 않습니다. DepthCameraEngine은 촬영·녹화·수동 제어·줌·터치 측광을 제공하지 않으며 CLI 제어 변경도 거절합니다. Photo·Video 또는 CLI 촬영 준비로 전환하면 Depth를 해제합니다.

    +

    제조사 Portrait도 일반 촬영과 별도의 확장 세션을 사용합니다. Bokeh 확장 전용 해상도로 컬러 미리보기와 JPEG만 요청하며, 세션이 거절되면 다른 크기로 자동 대체하지 않습니다. 단일 촬영의 JPEG를 받기 전에 실패하거나 종료되면 세션을 해제하여 늦은 이미지를 다음 촬영에 연결하지 않습니다. 이미 받은 JPEG의 저장 작업은 종료 후에도 마칠 수 있습니다.

    Multi 모드의 MultiSession은 CameraEngine과 별도입니다. Camera2는 선택한 독립 장치를 연 뒤 세션을 구성하며, CameraX는 광고된 동시 조합을 바인딩합니다.

    모든 장치의 종료 통지를 확인해야 출력을 해제하고 다음 동시 세션을 열 수 있습니다. 한 명령의 여러 사진을 센서 동기 촬영으로 해석하지 않습니다.

    Multi V의 MicrophoneCapture는 AudioRecord의 MONOTONIC 하드웨어 시각과 PCM 프레임 위치로 오디오 원점을 구합니다.

    @@ -670,9 +674,9 @@

    CLI의 접근과 완료 경계

    Live 카메라 종료 후 벤치마크 화면으로 이동하고 기존 측정·판정 규칙을 유지합니다.

    근거와 검토 정보
      -
    • 근거 파일: app/src/main/java/dev/halcamera/camera/MultiSession.kt, app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt, app/src/main/java/dev/halcamera/camera/MultiRecording.kt, app/src/main/java/dev/halcamera/camera/MicrophoneCapture.kt, app/src/main/java/dev/halcamera/camera/TimestampedVideoRecording.kt, app/src/main/java/dev/halcamera/camera/DeviceCompositor.kt, app/src/main/java/dev/halcamera/cli/CliProvider.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, app/src/main/java/dev/halcamera/cli/CommandStore.kt, app/src/main/java/dev/halcamera/camera/CameraEngine.kt, app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt, app/src/main/java/dev/halcamera/camera/CameraEndpointResolver.kt, app/src/main/java/dev/halcamera/telemetry/IncidentExporter.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RegressionRules.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkStore.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkIndex.kt, app/src/main/java/dev/halcamera/benchmark/domain/AtomicFiles.kt
    • +
    • 근거 파일: app/src/main/java/dev/halcamera/camera/MultiSession.kt, app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt, app/src/main/java/dev/halcamera/camera/MultiRecording.kt, app/src/main/java/dev/halcamera/camera/MicrophoneCapture.kt, app/src/main/java/dev/halcamera/camera/TimestampedVideoRecording.kt, app/src/main/java/dev/halcamera/camera/DeviceCompositor.kt, app/src/main/java/dev/halcamera/cli/CliProvider.kt, app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt, app/src/main/java/dev/halcamera/cli/CommandStore.kt, app/src/main/java/dev/halcamera/camera/CameraEngine.kt, app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt, app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt, app/src/main/java/dev/halcamera/camera/CameraEndpointResolver.kt, app/src/main/java/dev/halcamera/telemetry/IncidentExporter.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt, app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt, app/src/main/java/dev/halcamera/benchmark/domain/RegressionRules.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt, app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkStore.kt, app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkIndex.kt, app/src/main/java/dev/halcamera/benchmark/domain/AtomicFiles.kt
    • 근거 수준: 코드 확인
    • -
    • 검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review
    • +
    • 검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review

    변경 후 검증

    Android 의존성이 없는 러너와 평가 로직은 JVM 단위 테스트로 확인할 수 있습니다. 저장소 루트에서 실행하세요.

    @@ -712,17 +716,17 @@

    미완성 기능과 추가 검증

    - + - + - + @@ -732,32 +736,32 @@

    미완성 기능과 추가 검증

    - + - + - + - + - + - +
    구조 원본 data-flow 최신검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    구조 원본 overall-architecture 최신검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    구조 원본 state-transitions 최신검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    구조 원본 ui-camera-label 구조 원본 ui-tool-handoff 최신검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    구조 원본 ui-zoom 최신검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    원고 overview 최신검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    원고 module-roles 최신검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    원고 runtime-flow 최신검토 2026-10-19 @ 14ca1ae2 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review
    원고 constraints 최신검토 2026-10-19 @ b0648a05 · codex-user-authorized-depth-review검토 2026-10-19 @ 2fb9f2aa · codex-user-authorized-portrait-review

    표의 최신은 기록된 근거와 원고가 검토 이후 바뀌지 않았다는 뜻입니다. 모든 기기에서 동작을 검증했다는 뜻은 아닙니다.

    diff --git a/docs/design/APP-UI.md b/docs/design/APP-UI.md index 13508f48..bd26796c 100644 --- a/docs/design/APP-UI.md +++ b/docs/design/APP-UI.md @@ -22,11 +22,11 @@ AEB가 꺼져 있을 때 셔터를 길게 누르면 연사하고 손을 떼면 셔터 위에는 `AEB 0/3`, `Burst · 2`, `HDR…`처럼 현재 상태만 표시합니다. 결과는 같은 자리에 기본 5초 동안 표시하고 접근성 시간 연장 설정을 따릅니다. 상태 변화로 셔터 위치가 움직이지 않도록 한 줄 높이를 확보하며 긴 실패 이유는 줄바꿈합니다. TalkBack은 `Start burst` 동작과 `Stop burst` 셔터로 연사를 조작합니다. 합성 중 다음 AEB는 시작하지 않습니다. -## Live Depth +## Live Depth · Portrait -Live 하단의 Depth는 Camera2의 DEPTH16 해상도 선택창을 엽니다. 미지원 카메라는 이유를 표시합니다. 선택한 맵은 잘라내지 않고 표시하며 가까운 곳은 밝게, 먼 곳은 어둡게, 거리 0 또는 신뢰도 50% 미만은 검게 표시합니다. 중앙 거리와 표시 표본의 유효 비율도 읽을 수 있어야 합니다. +Live 하단의 Depth는 Off·Portrait (Bokeh)·Depth Map을 고르는 메뉴를 엽니다. 제조사 Bokeh와 센서 Depth 맵의 지원 여부를 따로 표시하고 미지원·읽기 실패의 이유를 보여줍니다. Depth Map은 DEPTH16 해상도를 선택하며 가까운 곳은 밝게, 먼 곳은 어둡게, 거리 0 또는 신뢰도 50% 미만은 검게 표시합니다. 중앙 거리와 표시 표본의 유효 비율도 읽을 수 있어야 합니다. -Depth에서는 촬영·녹화·PIP·줌·수동 제어·터치 측광을 비활성화합니다. Photo·Video로 돌아가거나 Depth에서 Off를 선택해 컬러 보기로 복귀합니다. 세션 실패 뒤에도 복귀 동작을 사용할 수 있어야 합니다. +Depth Map에서는 촬영·녹화·PIP·줌·수동 제어·터치 측광을 비활성화합니다. Portrait는 제조사 BOKEH 전용 미리보기·JPEG 크기를 선택한 뒤 사진 셔터를 제공합니다. Portrait에서도 녹화·RAW·연사·AEB·PIP·줌·수동 제어·터치 측광은 제공하지 않습니다. 활성 상태는 Depth 버튼과 크기 표시에 표시하며 EIS·일반 콜백 지표를 숨깁니다. Photo·Video나 Depth의 Off로 일반 컬러 촬영에 복귀합니다. 세션 실패 뒤에도 복귀와 해상도 재선택을 허용합니다. ## Lab과 기기 식별 @@ -169,7 +169,7 @@ Live의 카메라 선택창은 아래쪽 조작부를 숨기고 위쪽을 흐리 Live 프리뷰에서 두 손가락을 벌리거나 모으면 지원 범위 안에서 연속으로 줌을 바꿉니다. 가장 가까운 프리셋의 원에 현재 배율을 소수 첫째 자리까지 표시하며, 펼친 상태에서 그 원을 누르면 해당 프리셋 배율로 돌아갑니다. 화면의 카메라 선택 정보를 갱신해도 중간 배율은 유지합니다. -핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. +핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기·제조사 Portrait, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. | 조건 | 동작 | | --- | --- | diff --git a/docs/live.html b/docs/live.html index 3cb4d5f2..58769dcf 100644 --- a/docs/live.html +++ b/docs/live.html @@ -34,6 +34,7 @@

    Set. Capture. Inspect.

    같은 장면도, 촬영 조건을

    Photo로 한 장을 찍고, Video로 움직임을 담아 보세요. 처음에는 기본값이면 충분합니다.

    • 촬영 · 사진·연사·AEB·동영상을 촬영합니다.
    • +
    • Portrait · Depth 메뉴에서 제조사 Bokeh 사진을 선택합니다.
    • 수동 설정 · ISO·노출 시간·초점을 고정합니다.
    • 스트림 설정 · 출력 크기·FPS·포맷을 바꿉니다.
    • PIP · Multi · 보조 화면을 합성하거나 여러 카메라로 촬영합니다.
    • @@ -48,11 +49,23 @@

      Live에서 촬영하세요

      기본 사진은 카메라가 만든 JPEG 한 장입니다. DCIM/HALCamera에 저장하며, 촬영 메타데이터 JSON도 남깁니다.

      JPEG 목록에서 YUV를 고르면 앱이 변환한 JPEG를 저장합니다. Camera2에서 RAW를 켜면 DNG도 저장합니다.

      모드나 엔진을 바꾸면 새 촬영으로 시작합니다. PIP·스트림·줌·촬영 제어는 초기화됩니다. Callback의 고정 시간은 사용자 설정으로 유지합니다.

      +

      제조사 Portrait로 배경을 흐리세요

      +
        +
      1. Lab → Probe에서 카메라별 Portrait · Manufacturer Bokeh 지원 여부와 전용 미리보기·JPEG 해상도를 확인합니다.
      2. +
      3. Live의 Depth → Portrait (Bokeh)를 선택합니다.
      4. +
      5. JPEG 해상도를 고릅니다. 각 항목에 함께 구성할 미리보기 해상도를 표시합니다.
      6. +
      7. 미리보기가 준비되면 가운데 셔터로 촬영하고, 저장 완료 뒤 갤러리에서 JPEG를 확인합니다.
      8. +
      9. 일반 촬영으로 돌아가려면 Photo나 Video를 누릅니다. Depth → Off로도 Photo로 돌아갑니다.
      10. +
      +

      Portrait는 제조사가 처리한 컬러 미리보기와 JPEG를 받는 촬영 모드입니다. Depth 메뉴에서 선택하지만 센서 Depth 맵을 받는 기능은 아닙니다. DEPTH_OUTPUT이 없는 카메라도 Bokeh 확장을 제공할 수 있습니다. 이 앱의 제조사 Portrait는 Android 12 / API 31 이상에서 Camera2 확장 세션을 사용합니다.

      +

      선택하면 기존 카메라를 닫고 표시한 해상도로 세션을 엽니다. 나열된 크기라도 조합에 따라 제조사가 거절할 수 있으며, 다른 크기로 자동 대체하지 않습니다. 세션 구성 후 첫 미리보기 프레임은 15초, 촬영 요청 후 JPEG 수신은 60초까지 기다리고 실패 이유를 표시합니다. 실패 뒤에는 Depth 메뉴에서 해상도를 다시 고르거나 Photo로 돌아가세요.

      +

      Portrait에서는 동영상·RAW·YUV 사진·연사·AEB·PIP·줌·터치 측광·수동 제어를 제공하지 않습니다. 배경 흐림의 모양과 강도는 제조사가 정합니다. JPEG와 manufacturer_bokeh 촬영 메타데이터는 일반 사진과 같은 저장 경로에 남고 Benchmark JSON이나 진단 ZIP에 이미지 픽셀을 넣지 않습니다.

      +

      같은 모드로 Lab에서 돌아오거나 앱을 다시 표시하면 Portrait 해상도를 유지합니다. 카메라·엔진을 바꾸거나 일반 Live Streams 설정을 적용하면 Portrait를 종료합니다. 크기 표시는 일반 Live Streams 대신 Depth 선택 메뉴를 엽니다. 새 CLI 촬영 요청은 일반 Photo·Video 구성을 사용하며 Portrait를 이어받지 않습니다.

      Depth 맵을 보세요

      1. Lab → Probe에서 카메라의 Depth 섹션을 확인합니다.
      2. Live로 돌아와 카메라를 선택합니다.
      3. -
      4. 하단의 Depth를 누르고 DEPTH16 해상도를 선택합니다. 지원하지 않거나 정보를 읽지 못한 카메라는 이유를 표시합니다.
      5. +
      6. 하단의 Depth → Depth Map에서 DEPTH16 해상도를 선택합니다. 지원하지 않거나 정보를 읽지 못한 카메라는 Depth 메뉴에 이유를 표시합니다.
      7. 컬러 화면으로 돌아가려면 Photo 또는 Video를 누릅니다. Depth → Off로도 Photo로 돌아갈 수 있습니다.

      Depth는 Camera2의 깊이 맵만 표시하는 보기 모드입니다. 선택하면 기존 카메라를 닫은 뒤 Depth 전용 세션을 엽니다. 컬러와 Depth를 같은 요청에서 받을 수 없는 기기에서도 사용할 수 있으며, 컬러와 정렬된 합성 화면은 제공하지 않습니다. 사진·동영상 저장, 줌·터치 측광·수동 제어와 PIP는 사용할 수 없습니다.

      diff --git a/docs/probe.html b/docs/probe.html index cb34f37c..e8ddf7b2 100644 --- a/docs/probe.html +++ b/docs/probe.html @@ -49,10 +49,11 @@

      앱에서 Probe를 여세요

    • 섹션: 제목을 눌러 접거나 펼칩니다. 출력 형식별 크기는 STREAMS에서 확인합니다.
    • DEPTH: DEPTH_OUTPUT 지원 여부와 DEPTH16 해상도, 컬러와 Depth를 같은 요청에 포함할 수 있는지를 확인합니다. Allowed by HAL은 기기의 선언이며 특정 출력 조합의 동작을 보장하지 않습니다. Unknown은 해당 정보를 읽지 못한 상태입니다. 단독으로 열 수 없는 물리 카메라는 LIVE에서 직접 선택할 수 없습니다.
    • 필터: 단어가 들어간 줄을 찾습니다. 결과를 누르면 해당 항목으로 이동합니다.
    • +
    • PORTRAIT · MANUFACTURER BOKEH: 제조사 Bokeh 지원 여부와 확장 전용 미리보기·JPEG 해상도를 확인합니다. 일반 STREAMS 크기와 구분하며, 크기 조합의 성공 여부는 실제 촬영으로 확인해야 합니다. Android 12 미만이나 단독 선택할 수 없는 물리 카메라는 이유를 표시합니다. Bokeh 지원은 Depth 맵 공개를 뜻하지 않습니다.
    • REQUEST · RESULT KEYS / SESSION KEYS: 요청·결과에서 지원하는 key 이름입니다. 실제 촬영값은 아닙니다.
    -섹션 이름과 API별 표시 조건

    IDENTITY, CAPABILITIES, DEPTH, SENSOR, LENS, CONTROL, PROCESSING, REQUEST 뒤에 REQUEST · RESULT KEYS가 나옵니다.

    +섹션 이름과 API별 표시 조건

    IDENTITY, CAPABILITIES, DEPTH, PORTRAIT · MANUFACTURER BOKEH, SENSOR, LENS, CONTROL, PROCESSING, REQUEST 뒤에 REQUEST · RESULT KEYS가 나옵니다.

    SESSION KEYS는 API 28 이상, MANDATORY STREAM COMBINATIONS는 API 29 이상에서 표시합니다.

    STREAMS는 PRIVATE·JPEG 같은 출력 형식별로 나뉩니다. 이어서 HIGH SPEED VIDEO, REPROCESSING INPUTS, ALL CHARACTERISTICS가 나옵니다.

    enum 값은 가능한 경우 숫자 대신 상수 이름을 표시합니다.

    diff --git a/docs/validation/portrait-20261020/README.md b/docs/validation/portrait-20261020/README.md new file mode 100644 index 00000000..e29281e8 --- /dev/null +++ b/docs/validation/portrait-20261020/README.md @@ -0,0 +1,46 @@ +# 제조사 Bokeh Portrait 검증 + +2026-10-20에 Samsung SM-S936N, Android 16 / API 36에서 제조사 BOKEH 확장 조회와 촬영을 검증했습니다. 앱 0.30.0(691)을 서명이 일치하는 빌드로 갱신했으며 기존 앱 데이터를 삭제하지 않았습니다. + +## 확인한 동작 + +PROBE는 공개 카메라 0·1·2·3에서 BOKEH 지원과 확장 전용 미리보기·JPEG 크기를 보고했습니다. 물리 카메라 5·6은 독립적으로 선택할 수 없다는 이유를 표시했습니다. [PROBE 발췌](probe-summary.json)에 각 카메라의 전체 크기 목록이 있습니다. + +PROBE 요청 `dd81ce3e-9c78-4537-a7f6-f8034c9a06b7`는 오류 없이 완료됐습니다. 원본 JSON 371,945바이트와 TXT 264,439바이트를 PC로 수집하고 manifest 해시와 대조했습니다. SHA-256은 JSON `9d325cc099c776e4a3aea92bdd7865ce9cc5baee5e25fe3d875411b13835c137`, TXT `1acc4521cca0a0c4fad46e41dd591b440bf2c943b1de14da52732c758edc8757`입니다. + +최종 APK의 `portrait_checks=true` instrumentation은 아래 17개 항목을 통과했습니다. [실측 결과](portrait-checks.json)에는 JPEG 바이트 수·해시·실제 크기·EXIF 방향·효과 메타데이터가 있습니다. 사진 픽셀은 저장소에 포함하지 않습니다. + +| 공개 카메라 ID | 미리보기 | 확인한 JPEG 구성 | 실제 저장한 JPEG | EXIF 방향 | +| --- | --- | --- | --- | --- | +| 0 | 1440×1080 | 640×480, 4080×3060 | 4080×3060 | 6 | +| 1 | 1440×1080 | 640×480, 4000×3000 | 4000×3000 | 8 | +| 2 | 1440×1080 | 640×480, 4000×3000 | 4000×3000 | 6 | +| 3 | 1440×1080 | 640×480, 3392×2544 | 3392×2544 | 8 | + +각 카메라에서 두 크기로 프리뷰를 준비하고 큰 크기의 JPEG를 한 장 저장한 뒤 일반 Photo로 복귀했습니다. 세 번 재열기와 열기 도중 종료도 통과했습니다. 처음 수동으로 확인한 카메라 0의 1280×720 JPEG 촬영도 성공했습니다. + +`portrait_ui=true`는 `Depth → Portrait (Bokeh) → JPEG 640×480`, Depth 버튼 선택, 셔터 활성화, 줌 비활성화, Photo·Video·Off 복귀를 통과했습니다. 아래 화면은 실기기 앱의 View를 렌더링한 검증 자료입니다. 카메라 미리보기와 최근 사진 썸네일은 숨겼고 시스템 알림은 포함하지 않았습니다. + +| 메뉴 | 해상도 선택 | Portrait 실행 | +| --- | --- | --- | +| ![Depth 메뉴](depth-portrait-menu.png) | ![JPEG 해상도 선택](portrait-resolutions.png) | ![Portrait LIVE](live-portrait.png) | + +재설치 직후 실행한 UI 검사 한 번에서는 카메라 0의 BOKEH 지원 조회가 false여서 진입 검사가 실패했습니다. 추가 코드 수정이나 앱 재설치 없이 다시 실행한 UI 검사와 최종 17개 촬영 검사는 모두 통과했습니다. 원인은 확정하지 못했습니다. 앱은 미지원 응답을 지원으로 바꾸지 않으며 Depth 메뉴를 다시 열 때 현재 지원 여부를 새로 조회합니다. + +## Depth 이미지와 품질 검증의 범위 + +이 기기의 공개 Camera2 카메라는 DEPTH_OUTPUT·DEPTH16 출력을 제공하지 않았습니다. [BOKEH의 DEPTH_JPEG 조회](portrait-depth-metadata.json)도 공개 ID 0·1·2·3 모두 빈 목록이며 읽기 오류는 없었습니다. 별도로 검사한 Bokeh JPEG에는 표준 XMP/GDepth/DynamicDepth 표식이 없었습니다. 이번 구현은 제조사가 처리한 컬러 JPEG를 저장하며 센서 Depth 맵을 추출하지 않습니다. 제조사 전용 SDK의 가능성은 확인하지 않았습니다. + +모든 광고 해상도 조합, API 31~35, 다른 제조사, 장시간 실행, 강제 60초 타임아웃은 검증하지 않았습니다. 이번 사진은 저장 경로와 크기·방향을 확인한 자료이며 인물·머리카락 경계의 흐림 품질을 판정한 자료가 아닙니다. + +## 자동 검사와 변경사항 리뷰 + +실기기 검사는 `CliStoreInstrumentation`에 `portrait_checks=true`, `portrait_ui=true`, `portrait_metadata=true`를 각각 전달해 실행합니다. 이 로컬 검증 APK의 testApplicationId는 `dev.halcamera.depthchecks`입니다. 촬영 검사는 일반 MediaLibrary에 실제 사진을 저장합니다. + +앱 JVM 테스트 911개, `lintDebug`(오류·경고 없음), `assembleDebug`, `assembleDebugAndroidTest`가 통과했습니다. 문서 회귀 검사 37개가 통과했습니다. + +코드와 원고를 대조한 뒤 최신성 15개 항목의 검토 기록을 `codex-user-authorized-portrait-review`로 갱신했습니다. 기존 최신 항목의 검토자는 유지했습니다. `docflow check --build` 6단계에서 최신성·coverage·생성 일치·사이트 빌드를 통과했습니다. + +지원 선언과 Unknown·읽기 오류 구분, 확장 전용 크기 선택, close(done) 순서, 늦은 JPEG 차단, 한 번뿐인 촬영 완료, 실패 후 Photo 복귀, 저장 중 전환 방지, CLI 초기화, 일반 Live Streams 및 Benchmark 경계를 코드에 대조했습니다. 문서에는 Depth 메뉴 진입 경로, 확장 전용 JPEG 저장과 제어 제한, 수명주기·데이터 흐름을 반영했습니다. 이 기록은 사용자 요청에 따른 Codex의 코드 대조이며 별도 사람의 독립 리뷰를 뜻하지 않습니다. + +사용 방법은 [Live 가이드](../../../guide/live.md#제조사-portrait로-배경을-흐리세요)를 참고하세요. diff --git a/docs/validation/portrait-20261020/depth-portrait-menu.png b/docs/validation/portrait-20261020/depth-portrait-menu.png new file mode 100644 index 00000000..dd32d387 Binary files /dev/null and b/docs/validation/portrait-20261020/depth-portrait-menu.png differ diff --git a/docs/validation/portrait-20261020/live-portrait.png b/docs/validation/portrait-20261020/live-portrait.png new file mode 100644 index 00000000..4c7b67c3 Binary files /dev/null and b/docs/validation/portrait-20261020/live-portrait.png differ diff --git a/docs/validation/portrait-20261020/portrait-checks.json b/docs/validation/portrait-20261020/portrait-checks.json new file mode 100644 index 00000000..c0ea0224 --- /dev/null +++ b/docs/validation/portrait-20261020/portrait-checks.json @@ -0,0 +1,139 @@ +{ + "model": "SM-S936N", + "android": "16", + "api": 36, + "checks": [ + { + "scenario": "preview", + "camera": "0", + "preview": "1440x1080", + "jpeg": "640x480", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "0", + "preview": "1440x1080", + "jpeg": "4080x3060", + "result": "PASS" + }, + { + "scenario": "capture", + "camera": "0", + "jpeg": "4080x3060", + "name": "HAL_20261020_021604_278_3768e9_JPEG.jpg", + "bytes": 1601011, + "sha256": "0ea4e9bf5716eeb6ccb92eeb8fe8cb99921e21474987cb7eefd3d380bc3df4b3", + "encodedWidth": 4080, + "encodedHeight": 3060, + "exifOrientation": 6, + "effect": "manufacturer_bokeh", + "result": "PASS" + }, + { + "scenario": "return_to_photo", + "camera": "0", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "1", + "preview": "1440x1080", + "jpeg": "640x480", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "1", + "preview": "1440x1080", + "jpeg": "4000x3000", + "result": "PASS" + }, + { + "scenario": "capture", + "camera": "1", + "jpeg": "4000x3000", + "name": "HAL_20261020_021607_237_a4840c_JPEG.jpg", + "bytes": 3815626, + "sha256": "7c4a48cae6b75a7a296d1df7bb475750d1c84f53f550f5617b74b4a8d8daa2e3", + "encodedWidth": 4000, + "encodedHeight": 3000, + "exifOrientation": 8, + "effect": "manufacturer_bokeh", + "result": "PASS" + }, + { + "scenario": "return_to_photo", + "camera": "1", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "2", + "preview": "1440x1080", + "jpeg": "640x480", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "2", + "preview": "1440x1080", + "jpeg": "4000x3000", + "result": "PASS" + }, + { + "scenario": "capture", + "camera": "2", + "jpeg": "4000x3000", + "name": "HAL_20261020_021609_825_fc7fe1_JPEG.jpg", + "bytes": 1315877, + "sha256": "6841ee864c017e7897e9eccace76b460899cbaf1e4bf15435ee4d58e895d9e10", + "encodedWidth": 4000, + "encodedHeight": 3000, + "exifOrientation": 6, + "effect": "manufacturer_bokeh", + "result": "PASS" + }, + { + "scenario": "return_to_photo", + "camera": "2", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "3", + "preview": "1440x1080", + "jpeg": "640x480", + "result": "PASS" + }, + { + "scenario": "preview", + "camera": "3", + "preview": "1440x1080", + "jpeg": "3392x2544", + "result": "PASS" + }, + { + "scenario": "capture", + "camera": "3", + "jpeg": "3392x2544", + "name": "HAL_20261020_021612_309_78e803_JPEG.jpg", + "bytes": 2811331, + "sha256": "5df424c2290de14d3aa3e447664b3b931aa49173556792cd97ede28c8dcc0e94", + "encodedWidth": 3392, + "encodedHeight": 2544, + "exifOrientation": 8, + "effect": "manufacturer_bokeh", + "result": "PASS" + }, + { + "scenario": "return_to_photo", + "camera": "3", + "result": "PASS" + }, + { + "scenario": "three_reopens_and_cancel_open", + "result": "PASS" + } + ] +} \ No newline at end of file diff --git a/docs/validation/portrait-20261020/portrait-depth-metadata.json b/docs/validation/portrait-20261020/portrait-depth-metadata.json new file mode 100644 index 00000000..8eb73194 --- /dev/null +++ b/docs/validation/portrait-20261020/portrait-depth-metadata.json @@ -0,0 +1,26 @@ +{ + "model": "SM-S936N", + "api": 36, + "cameras": [ + { + "camera": "0", + "bokehDepthJpegSizes": [], + "readError": null + }, + { + "camera": "1", + "bokehDepthJpegSizes": [], + "readError": null + }, + { + "camera": "2", + "bokehDepthJpegSizes": [], + "readError": null + }, + { + "camera": "3", + "bokehDepthJpegSizes": [], + "readError": null + } + ] +} \ No newline at end of file diff --git a/docs/validation/portrait-20261020/portrait-resolutions.png b/docs/validation/portrait-20261020/portrait-resolutions.png new file mode 100644 index 00000000..8f29f52a Binary files /dev/null and b/docs/validation/portrait-20261020/portrait-resolutions.png differ diff --git a/docs/validation/portrait-20261020/probe-summary.json b/docs/validation/portrait-20261020/probe-summary.json new file mode 100644 index 00000000..c6537faf --- /dev/null +++ b/docs/validation/portrait-20261020/probe-summary.json @@ -0,0 +1,134 @@ +[ + { + "camera": "0", + "physical_of": null, + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Supported" + }, + { + "key": "Bokeh preview resolutions", + "value": "176x144, 320x240, 352x288, 640x360, 640x480, 720x480, 960x720, 1280x720, 1232x1008, 1440x1080, 1920x824, 1920x1080" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "640x480, 720x480, 960x720, 1280x720, 1088x1088, 1232x1008, 1440x1080, 1920x824, 1920x1080, 2336x1080, 2400x1080, 1920x1440, 2560x1440, 4000x1716, 4032x1728, 4000x1848, 3840x2160, 2992x2992, 4000x2252, 3648x2736, 4000x3000, 4080x3060" + }, + { + "key": "LIVE Portrait", + "value": "Photo only; size combination must still be checked" + } + ] + }, + { + "camera": "5", + "physical_of": "0", + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Unknown" + }, + { + "key": "Bokeh preview resolutions", + "value": "None reported" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "None reported" + }, + { + "key": "LIVE Portrait", + "value": "Physical camera: select a publicly listed camera ID in LIVE" + } + ] + }, + { + "camera": "6", + "physical_of": "0", + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Unknown" + }, + { + "key": "Bokeh preview resolutions", + "value": "None reported" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "None reported" + }, + { + "key": "LIVE Portrait", + "value": "Physical camera: select a publicly listed camera ID in LIVE" + } + ] + }, + { + "camera": "1", + "physical_of": null, + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Supported" + }, + { + "key": "Bokeh preview resolutions", + "value": "176x144, 320x240, 352x288, 640x360, 640x480, 720x480, 960x720, 1280x720, 1232x1008, 1440x1080, 1920x824, 1920x1080" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "640x480, 720x480, 960x720, 1280x720, 1088x1088, 1232x1008, 1440x1080, 1920x824, 1920x1080, 2336x1080, 2400x1080, 1920x1440, 2560x1440, 3648x1640, 3648x2048, 2736x2736, 3840x2160, 4000x2252, 3648x2736, 4000x3000" + }, + { + "key": "LIVE Portrait", + "value": "Photo only; size combination must still be checked" + } + ] + }, + { + "camera": "2", + "physical_of": null, + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Supported" + }, + { + "key": "Bokeh preview resolutions", + "value": "176x144, 320x240, 352x288, 640x360, 640x480, 720x480, 960x720, 1280x720, 1232x1008, 1440x1080, 1920x824, 1920x1080" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "640x480, 720x480, 960x720, 1280x720, 1088x1088, 1232x1008, 1440x1080, 1920x824, 1920x1080, 2336x1080, 2400x1080, 1920x1440, 2560x1440, 3840x2160, 4000x2252, 3648x2736, 4000x3000" + }, + { + "key": "LIVE Portrait", + "value": "Photo only; size combination must still be checked" + } + ] + }, + { + "camera": "3", + "physical_of": null, + "portrait": [ + { + "key": "Manufacturer BOKEH", + "value": "Supported" + }, + { + "key": "Bokeh preview resolutions", + "value": "176x144, 320x240, 352x288, 640x360, 640x480, 720x480, 960x720, 1280x720, 1232x1008, 1440x980, 1440x1080, 1920x824, 1920x1080" + }, + { + "key": "Bokeh JPEG resolutions", + "value": "640x480, 720x480, 960x720, 1280x720, 1088x1088, 1232x1008, 1440x980, 1440x1080, 1920x824, 1920x1080, 2336x1080, 2400x1080, 1920x1440, 2560x1440, 2208x2208, 3392x1908, 3392x2544" + }, + { + "key": "LIVE Portrait", + "value": "Photo only; size combination must still be checked" + } + ] + } +] diff --git a/guide/_bindings.yaml b/guide/_bindings.yaml index e879479b..d0bc05f8 100644 --- a/guide/_bindings.yaml +++ b/guide/_bindings.yaml @@ -125,6 +125,12 @@ sources: - app/src/main/java/dev/halcamera/camera/DepthSupport.kt - app/src/main/java/dev/halcamera/camera/DepthFrame.kt - app/src/main/java/dev/halcamera/ui/DepthMapView.kt + camera-engines/portrait-engine: + evidence: + - app/src/main/java/dev/halcamera/MainPortraitBridge.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt + - app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt + - app/src/main/java/dev/halcamera/camera/PortraitSupport.kt camera-engines/camera2-engine: evidence: - app/src/main/java/dev/halcamera/camera/Camera2Engine.kt @@ -203,6 +209,9 @@ sources: screens/expert-screen: evidence: - app/src/main/java/dev/halcamera/MainActivity.kt + screens/expert-screen/camera-picker: + evidence: + - app/src/main/java/dev/halcamera/MainCameraPicker.kt # MainActivity 하나만으로도 스캔 입력이 6만 자 한도에 가깝습니다. 최근 촬영물 조회는 화면 배치와 별개의 # 주제이므로 하위 요소로 내려, 둘 다 한도 안에서 스캔되게 합니다. screens/expert-screen/cli-adapter: @@ -369,6 +378,8 @@ sources: - app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt - app/src/main/java/dev/halcamera/camera/DepthCapabilities.kt - app/src/main/java/dev/halcamera/camera/DepthSupport.kt + - app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt + - app/src/main/java/dev/halcamera/camera/PortraitSupport.kt screens/cts-screen: evidence: - app/src/main/java/dev/halcamera/cts/CtsEntryActivity.kt @@ -596,6 +607,9 @@ sources: # data-flow 와 같은 규칙입니다: 요소 근거와 원고 인용의 합집합만 적습니다. evidence: - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/MainDepthBridge.kt + - app/src/main/java/dev/halcamera/MainPortraitBridge.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt - app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt - app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt @@ -656,6 +670,11 @@ sources: engine-lifecycle/camerax-lifecycle: evidence: - app/src/main/java/dev/halcamera/camera/CameraXEngine.kt + engine-lifecycle/portrait-lifecycle: + evidence: + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt + - app/src/main/java/dev/halcamera/MainPortraitBridge.kt + - app/src/main/java/dev/halcamera/MainDepthBridge.kt runner-sequence: evidence: - app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt @@ -706,11 +725,13 @@ sources: - app/src/main/java/dev/halcamera/ui/ExpandingZoomControl.kt - app/src/main/java/dev/halcamera/ui/FocusRing.kt - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/MainCameraPicker.kt elements: .: evidence: - app/src/main/java/dev/halcamera/ui/ExpandingZoomControl.kt - app/src/main/java/dev/halcamera/ui/FocusRing.kt + - app/src/main/java/dev/halcamera/MainCameraPicker.kt telemetry-facts: kind: fact diff --git a/guide/_content/_reference/engine/camera2.md b/guide/_content/_reference/engine/camera2.md index ecf4309c..6bea2e38 100644 --- a/guide/_content/_reference/engine/camera2.md +++ b/guide/_content/_reference/engine/camera2.md @@ -3,6 +3,7 @@ based_on: [overall-architecture] confidence: code sources: - app/src/main/java/dev/halcamera/camera/Camera2Engine.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/camera/LivePipSession.kt - app/src/main/java/dev/halcamera/camera/PipSessionQuery.kt - app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt @@ -66,6 +67,8 @@ Camera2 Live의 프리뷰·사진·녹화·녹화 중 사진 요청은 기존 색온도를 추정하지 않습니다. +제조사 Portrait는 일반 Camera2Engine의 스트림에 섞지 않습니다. API 31 이상에서 PortraitCameraEngine이 BOKEH 확장 전용 미리보기·JPEG 크기를 재검사하고 CameraExtensionSession을 엽니다. JPEG는 MediaLibrary로 저장하며, 미리보기 15초·JPEG 60초 시간 제한과 CameraDevice.onClosed 이후 close(done) 완료 순서를 적용합니다. +
    ### 손떨림 보정 diff --git a/guide/_content/_reference/engine/comparison.md b/guide/_content/_reference/engine/comparison.md index c6eb5ba1..a9f76039 100644 --- a/guide/_content/_reference/engine/comparison.md +++ b/guide/_content/_reference/engine/comparison.md @@ -15,6 +15,7 @@ sources: - app/src/main/java/dev/halcamera/camera/CameraXLiveRecorder.kt - app/src/main/java/dev/halcamera/camera/CameraXControls.kt - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt - app/src/main/java/dev/halcamera/camera/LivePipSession.kt - app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt @@ -27,6 +28,7 @@ verifications: [] | 항목 | Camera2 | CameraX | | --- | --- | --- | | Depth 보기 | DEPTH_OUTPUT과 DEPTH16 해상도를 공개한 카메라에서 별도 Depth 전용 세션을 엽니다. 맵 표시만 제공합니다. | Depth 해상도를 선택하면 Camera2로 전환합니다. | +| 제조사 Portrait | API 31 이상의 BOKEH 확장 세션으로 전용 미리보기와 JPEG를 받습니다. Depth 메뉴에서 선택하며 수동 제어·RAW·PIP·영상은 제공하지 않습니다. | Portrait를 선택하면 Camera2로 전환합니다. | | 수동 촬영 | ISO·노출 시간·초점과 WB를 프리뷰·사진·녹화에 적용합니다. 지원 여부와 실제 적용값을 표시합니다. | Manual 버튼을 미지원 상태로 비활성화합니다. | | YUV를 JPEG로 선택 | 요청한 YUV 이미지와 최종 CaptureResult의 센서 시각을 맞춥니다. | 촬영 뒤 받은 analysis 프레임 하나를 변환합니다. | | JPEG 출처 | YUV 앱 변환 또는 HAL JPEG 중 하나와 촬영 JSON을 저장합니다. | YUV 앱 변환 또는 ImageCapture JPEG 중 하나와 촬영 JSON을 저장합니다. | diff --git a/guide/_content/_reference/engine/contract.md b/guide/_content/_reference/engine/contract.md index 3fb63931..6433ba7e 100644 --- a/guide/_content/_reference/engine/contract.md +++ b/guide/_content/_reference/engine/contract.md @@ -7,6 +7,7 @@ sources: - app/src/main/java/dev/halcamera/camera/CameraXEngine.kt - app/src/main/java/dev/halcamera/camera/DepthCameraEngine.kt - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/ui/LiveControlBar.kt - app/src/main/java/dev/halcamera/ui/FocusRing.kt - app/src/main/java/dev/halcamera/camera/LiveControls.kt @@ -95,3 +96,5 @@ Live 제어와 터치 측광은 다음 이벤트를 추가로 남깁니다. | CameraX에서 EIS (Video)를 선택합니다. | VideoCapture가 연결된 녹화 중에 대조합니다. 녹화 전에는 실제 결과만 표시합니다. | 사진 결과는 Camera2의 요청 태그와 CameraX의 `captureIntent`로 제외합니다. 설정 화면은 구성 실패 오류만 표시하며 이전 세션 결과 목록을 제공하지 않습니다. + +제조사 Portrait는 별도 `CameraExtensionSession`을 사용합니다. 일반 `CaptureResult` 지표와 EIS 대조를 표시하지 않으며 수동 제어·줌·측광도 제공하지 않습니다. Depth 메뉴에서 선택하지만 출력은 OEM 컬러 미리보기와 JPEG이며, 센서 Depth 맵과 구분합니다. diff --git a/guide/_content/architecture/constraints.md b/guide/_content/architecture/constraints.md index 68b24cbe..763bb436 100644 --- a/guide/_content/architecture/constraints.md +++ b/guide/_content/architecture/constraints.md @@ -12,6 +12,7 @@ sources: - app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt - app/src/main/java/dev/halcamera/cli/CommandStore.kt - app/src/main/java/dev/halcamera/camera/CameraEngine.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt - app/src/main/java/dev/halcamera/camera/CameraEndpointResolver.kt - app/src/main/java/dev/halcamera/telemetry/IncidentExporter.kt @@ -42,6 +43,8 @@ verifications: [] Depth 보기는 컬러와 Depth를 같은 요청에 포함하지 않는 별도 Camera2 세션입니다. HAL의 동시 요청 허용 선언은 특정 출력 조합의 성공을 보장하지 않습니다. `DepthCameraEngine`은 촬영·녹화·수동 제어·줌·터치 측광을 제공하지 않으며 CLI 제어 변경도 거절합니다. Photo·Video 또는 CLI 촬영 준비로 전환하면 Depth를 해제합니다. +제조사 Portrait도 일반 촬영과 별도의 확장 세션을 사용합니다. Bokeh 확장 전용 해상도로 컬러 미리보기와 JPEG만 요청하며, 세션이 거절되면 다른 크기로 자동 대체하지 않습니다. 단일 촬영의 JPEG를 받기 전에 실패하거나 종료되면 세션을 해제하여 늦은 이미지를 다음 촬영에 연결하지 않습니다. 이미 받은 JPEG의 저장 작업은 종료 후에도 마칠 수 있습니다. + Multi 모드의 `MultiSession`은 `CameraEngine`과 별도입니다. Camera2는 선택한 독립 장치를 연 뒤 세션을 구성하며, CameraX는 광고된 동시 조합을 바인딩합니다. 모든 장치의 종료 통지를 확인해야 출력을 해제하고 다음 동시 세션을 열 수 있습니다. 한 명령의 여러 사진을 센서 동기 촬영으로 해석하지 않습니다. diff --git a/guide/_content/architecture/module-roles.md b/guide/_content/architecture/module-roles.md index 4a224f72..eacd8525 100644 --- a/guide/_content/architecture/module-roles.md +++ b/guide/_content/architecture/module-roles.md @@ -23,6 +23,7 @@ sources: - app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt - app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt - app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt - app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt @@ -95,6 +96,7 @@ verifications: [] camera/ - 엔진 계약, Camera2·CameraX 구현, 엔드포인트 열거를 제공합니다. `DepthCameraEngine`은 컬러 출력 없이 DEPTH16 맵만 받으며 `DepthFrame`이 순수 Kotlin으로 표시용 표본을 만듭니다. +- `PortraitCameraEngine`은 LIVE의 제조사 BOKEH 전용 세션입니다. MainPortraitBridge가 JPEG 크기를 선택하고 MainDepthBridge가 Depth Map과 Portrait의 진입점을 구분합니다. 이 경로는 벤치마크를 실행하거나 센서 Depth 맵을 저장하지 않습니다. - `close(done)` 완료 전에 다음 카메라를 열지 않습니다. - 엔진 계약(`MediaCapture`, `LiveTuning`, `TouchMetering`)과 두 엔진의 구현, 두 엔진의 차이는 [Engine Comparison](engine.md)에 있습니다. - 벤치마크 RECORD 단계의 recorder 상태 기계는 `BenchmarkRecorder`가 담당하고 `Camera2Engine`은 그 호출을 위임합니다. diff --git a/guide/_content/architecture/overview.md b/guide/_content/architecture/overview.md index a2372915..32be428c 100644 --- a/guide/_content/architecture/overview.md +++ b/guide/_content/architecture/overview.md @@ -32,7 +32,7 @@ verifications: [] | 화면 | 할 수 있는 일 | | --- | --- | -| Live · `MainActivity` | 앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며 지원 카메라의 Depth 맵을 봅니다. | +| Live · `MainActivity` | 앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며, 지원 카메라의 제조사 Portrait와 Depth 맵을 선택합니다. | | Multi · `ConcurrentCameraActivity` | 독립 카메라 장치별 사진·영상을 제공합니다. Live의 Multi · P와 Multi · V에서 엽니다. | | Lab · `WorkbenchActivity` | 기기 정보, 검사 도구, 저장된 결과와 설정을 엽니다. | | Benchmark · `BenchmarkActivity` | 정해진 조건(profile)으로 측정하고 결과를 저장합니다. | diff --git a/guide/_content/architecture/runtime-flow.md b/guide/_content/architecture/runtime-flow.md index b06d5103..e4540b4c 100644 --- a/guide/_content/architecture/runtime-flow.md +++ b/guide/_content/architecture/runtime-flow.md @@ -7,6 +7,7 @@ sources: - tools/halcam/halcam/cli.py - tools/halcam/halcam/download.py - app/src/main/java/dev/halcamera/MainActivity.kt + - app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt - app/src/main/java/dev/halcamera/GalleryActivity.kt - app/src/main/java/dev/halcamera/camera/MediaLibrary.kt - app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt @@ -41,6 +42,8 @@ Live 셔터 조작은 `MainActivity`에서 선택한 엔진의 촬영·녹화 Live의 Depth 보기는 `DepthCameraEngine → ImageReader → DepthFrame → DepthMapView`로 연결합니다. 컬러 촬영과 분리된 보기이며, Telemetry에는 버퍼의 시각·크기·형식과 capture 콜백만 기록합니다. 맵 픽셀을 벤치마크나 incident ZIP의 입력으로 전달하지 않습니다. 사용 순서는 [Depth 맵 보기](live.md#depth-맵을-보세요)에 있습니다. +같은 Depth 메뉴의 Portrait는 `PortraitCameraEngine → CameraExtensionSession → JPEG ImageReader → MediaLibrary`로 연결합니다. 제조사 Bokeh가 적용된 컬러 사진을 저장하며 별도의 센서 Depth 맵을 받지 않습니다. 확장 처리 이벤트는 일반 Camera2 프레임 지표와 구분하고, 사진 픽셀은 측정 파일에 넣지 않습니다. + 벤치마크는 별도 화면인 `BenchmarkActivity`가 준비를 마친 뒤 `BenchmarkRunner.start()`를 호출하여 시작합니다. `Telemetry.callback(...)`이 반환한 Camera2 콜백의 `onCaptureStarted`는 프레임워크 신호를 기록하며, Live 셔터와 벤치마크 시작을 연결하는 메서드가 아닙니다. diff --git a/guide/_reference/engine.md b/guide/_reference/engine.md index ea334c84..41137a14 100644 --- a/guide/_reference/engine.md +++ b/guide/_reference/engine.md @@ -24,6 +24,7 @@ nav_order: 5 | 항목 | Camera2 | CameraX | | --- | --- | --- | | Depth 보기 | DEPTH_OUTPUT과 DEPTH16 해상도를 공개한 카메라에서 별도 Depth 전용 세션을 엽니다. 맵 표시만 제공합니다. | Depth 해상도를 선택하면 Camera2로 전환합니다. | +| 제조사 Portrait | API 31 이상의 BOKEH 확장 세션으로 전용 미리보기와 JPEG를 받습니다. Depth 메뉴에서 선택하며 수동 제어·RAW·PIP·영상은 제공하지 않습니다. | Portrait를 선택하면 Camera2로 전환합니다. | | 수동 촬영 | ISO·노출 시간·초점과 WB를 프리뷰·사진·녹화에 적용합니다. 지원 여부와 실제 적용값을 표시합니다. | Manual 버튼을 미지원 상태로 비활성화합니다. | | YUV를 JPEG로 선택 | 요청한 YUV 이미지와 최종 CaptureResult의 센서 시각을 맞춥니다. | 촬영 뒤 받은 analysis 프레임 하나를 변환합니다. | | JPEG 출처 | YUV 앱 변환 또는 HAL JPEG 중 하나와 촬영 JSON을 저장합니다. | YUV 앱 변환 또는 ImageCapture JPEG 중 하나와 촬영 JSON을 저장합니다. | @@ -58,9 +59,9 @@ PIP와 Multi는 Android 11 이상에서 제공합니다. 저장물과 설정 방
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/Camera2StillCapture.kt`, `app/src/main/java/dev/halcamera/camera/Camera2LiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeterRequests.kt`, `app/src/main/java/dev/halcamera/camera/CameraXEngine.kt`, `app/src/main/java/dev/halcamera/camera/CameraXPipSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXStillCapture.kt`, `app/src/main/java/dev/halcamera/camera/CameraXLiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/CameraXControls.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/LivePipSession.kt`, `app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/Camera2StillCapture.kt`, `app/src/main/java/dev/halcamera/camera/Camera2LiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeterRequests.kt`, `app/src/main/java/dev/halcamera/camera/CameraXEngine.kt`, `app/src/main/java/dev/halcamera/camera/CameraXPipSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXStillCapture.kt`, `app/src/main/java/dev/halcamera/camera/CameraXLiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/CameraXControls.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/LivePipSession.kt`, `app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -214,12 +215,14 @@ Live 제어와 터치 측광은 다음 이벤트를 추가로 남깁니다. 사진 결과는 Camera2의 요청 태그와 CameraX의 `captureIntent`로 제외합니다. 설정 화면은 구성 실패 오류만 표시하며 이전 세션 결과 목록을 제공하지 않습니다. +제조사 Portrait는 별도 `CameraExtensionSession`을 사용합니다. 일반 `CaptureResult` 지표와 EIS 대조를 표시하지 않으며 수동 제어·줌·측광도 제공하지 않습니다. Depth 메뉴에서 선택하지만 출력은 OEM 컬러 미리보기와 JPEG이며, 센서 Depth 맵과 구분합니다. +
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/CameraXEngine.kt`, `app/src/main/java/dev/halcamera/camera/DepthCameraEngine.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/ui/LiveControlBar.kt`, `app/src/main/java/dev/halcamera/ui/FocusRing.kt`, `app/src/main/java/dev/halcamera/camera/LiveControls.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/StartCardPresenter.kt`, `app/src/main/java/dev/halcamera/cli/LiveController.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/CameraXEngine.kt`, `app/src/main/java/dev/halcamera/camera/DepthCameraEngine.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/ui/LiveControlBar.kt`, `app/src/main/java/dev/halcamera/ui/FocusRing.kt`, `app/src/main/java/dev/halcamera/camera/LiveControls.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/StartCardPresenter.kt`, `app/src/main/java/dev/halcamera/cli/LiveController.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -265,6 +268,8 @@ Camera2 Live의 프리뷰·사진·녹화·녹화 중 사진 요청은 기존 색온도를 추정하지 않습니다. +제조사 Portrait는 일반 Camera2Engine의 스트림에 섞지 않습니다. API 31 이상에서 PortraitCameraEngine이 BOKEH 확장 전용 미리보기·JPEG 크기를 재검사하고 CameraExtensionSession을 엽니다. JPEG는 MediaLibrary로 저장하며, 미리보기 15초·JPEG 60초 시간 제한과 CameraDevice.onClosed 이후 close(done) 완료 순서를 적용합니다. + ### 손떨림 보정 @@ -601,9 +606,9 @@ Camera2에서 짧게 터치한 경우입니다. 긴 누르기는 별도의 AE
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/LivePipSession.kt`, `app/src/main/java/dev/halcamera/camera/PipSessionQuery.kt`, `app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt`, `app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt`, `app/src/main/java/dev/halcamera/camera/LiveStreamSettings.kt`, `app/src/main/java/dev/halcamera/camera/LiveStabilization.kt`, `app/src/main/java/dev/halcamera/camera/LiveStreamCapabilities.kt`, `app/src/main/java/dev/halcamera/camera/LiveSessionCheck.kt`, `app/src/main/java/dev/halcamera/camera/Camera2StillCapture.kt`, `app/src/main/java/dev/halcamera/camera/Camera2LiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/Camera2VideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/VideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt`, `app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt`, `app/src/main/java/dev/halcamera/camera/RecordingBufferRelay.kt`, `app/src/main/java/dev/halcamera/camera/StreamConfiguration.kt`, `app/src/main/java/dev/halcamera/camera/StillPair.kt`, `app/src/main/java/dev/halcamera/camera/YuvPacking.kt`, `app/src/main/java/dev/halcamera/camera/RawFrame.kt`, `app/src/main/java/dev/halcamera/camera/DngOutput.kt`, `app/src/main/java/dev/halcamera/camera/StillEncoding.kt`, `app/src/main/java/dev/halcamera/camera/MediaLibrary.kt`, `app/src/main/java/dev/halcamera/camera/LiveControls.kt`, `app/src/main/java/dev/halcamera/camera/LiveControlRequests.kt`, `app/src/main/java/dev/halcamera/camera/ManualControls.kt`, `app/src/main/java/dev/halcamera/camera/ManualControlRequests.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeter.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeterRequests.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/LivePipSession.kt`, `app/src/main/java/dev/halcamera/camera/PipSessionQuery.kt`, `app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt`, `app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt`, `app/src/main/java/dev/halcamera/camera/LiveStreamSettings.kt`, `app/src/main/java/dev/halcamera/camera/LiveStabilization.kt`, `app/src/main/java/dev/halcamera/camera/LiveStreamCapabilities.kt`, `app/src/main/java/dev/halcamera/camera/LiveSessionCheck.kt`, `app/src/main/java/dev/halcamera/camera/Camera2StillCapture.kt`, `app/src/main/java/dev/halcamera/camera/Camera2LiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/Camera2VideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/VideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt`, `app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt`, `app/src/main/java/dev/halcamera/camera/RecordingBufferRelay.kt`, `app/src/main/java/dev/halcamera/camera/StreamConfiguration.kt`, `app/src/main/java/dev/halcamera/camera/StillPair.kt`, `app/src/main/java/dev/halcamera/camera/YuvPacking.kt`, `app/src/main/java/dev/halcamera/camera/RawFrame.kt`, `app/src/main/java/dev/halcamera/camera/DngOutput.kt`, `app/src/main/java/dev/halcamera/camera/StillEncoding.kt`, `app/src/main/java/dev/halcamera/camera/MediaLibrary.kt`, `app/src/main/java/dev/halcamera/camera/LiveControls.kt`, `app/src/main/java/dev/halcamera/camera/LiveControlRequests.kt`, `app/src/main/java/dev/halcamera/camera/ManualControls.kt`, `app/src/main/java/dev/halcamera/camera/ManualControlRequests.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeter.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeterRequests.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -822,7 +827,7 @@ AE 재잠금은 Camera2와 같은 [AeRelock 상태도](#노출은-언제-다시- - 근거 파일: `app/src/main/java/dev/halcamera/camera/CameraXEngine.kt`, `app/src/main/java/dev/halcamera/camera/CameraXPipSession.kt`, `app/src/main/java/dev/halcamera/camera/PipSessionQuery.kt`, `app/src/main/java/dev/halcamera/camera/CameraXStillCapture.kt`, `app/src/main/java/dev/halcamera/camera/CameraXLiveRecorder.kt`, `app/src/main/java/dev/halcamera/camera/CameraXVideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/VideoSnapshot.kt`, `app/src/main/java/dev/halcamera/camera/CameraXControls.kt`, `app/src/main/java/dev/halcamera/camera/StillEncoding.kt`, `app/src/main/java/dev/halcamera/camera/YuvPacking.kt`, `app/src/main/java/dev/halcamera/camera/MediaLibrary.kt`, `app/src/main/java/dev/halcamera/camera/StreamConfiguration.kt`, `app/src/main/java/dev/halcamera/camera/LiveControls.kt`, `app/src/main/java/dev/halcamera/camera/TouchMeter.kt`, `app/build.gradle.kts` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review @@ -842,11 +847,11 @@ AE 재잠금은 Camera2와 같은 [AeRelock 상태도](#노출은-언제-다시- | 항목 | 최신성 | 검토 | | --- | --- | --- | -| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `contract` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `camera2` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `camerax` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `comparison` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | +| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `contract` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `camera2` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `camerax` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `comparison` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | diff --git a/guide/_reference/evidence.md b/guide/_reference/evidence.md index 8c5f7f9b..4ee9868d 100644 --- a/guide/_reference/evidence.md +++ b/guide/_reference/evidence.md @@ -80,7 +80,7 @@ Galaxy S25+·Android 16에서 2026년 9월 17~19일, versionCode 106~108로 수 - 근거 파일: `app/src/main/java/dev/halcamera/camera/CameraXControls.kt`, `app/src/main/java/dev/halcamera/camera/CameraXStillCapture.kt`, `app/src/main/java/dev/halcamera/camera/CameraXLiveRecorder.kt` - 기기 검증: `V-002` - 근거 수준: 기기 검증 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review @@ -98,8 +98,8 @@ Galaxy S25+·Android 16에서 2026년 9월 17~19일, versionCode 106~108로 수 | 항목 | 최신성 | 검토 | | --- | --- | --- | -| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `device-notes` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | +| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `device-notes` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | diff --git a/guide/_reference/troubleshooting.md b/guide/_reference/troubleshooting.md index 0cb500ca..c5ffd7f7 100644 --- a/guide/_reference/troubleshooting.md +++ b/guide/_reference/troubleshooting.md @@ -140,7 +140,7 @@ PC의 대기 시간이 끝나도 앱 작업은 계속될 수 있습니다. 요 - 근거 파일: `app/src/main/java/dev/halcamera/cli/CommandStore.kt`, `tools/halcam/halcam/cli.py`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/CameraXStillCapture.kt`, `app/src/main/java/dev/halcamera/camera/Camera2Engine.kt`, `app/src/main/java/dev/halcamera/camera/ManualControlRequests.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/metrics/MetricExtractor.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review @@ -172,9 +172,9 @@ PC의 대기 시간이 끝나도 앱 작업은 계속될 수 있습니다. 요 | 항목 | 최신성 | 검토 | | --- | --- | --- | -| 구조 원본 `data-flow` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 구조 원본 `state-transitions` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 원고 `layer-isolation` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | +| 구조 원본 `data-flow` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 구조 원본 `state-transitions` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `layer-isolation` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | diff --git a/guide/architecture.md b/guide/architecture.md index 2154b888..02e190ae 100644 --- a/guide/architecture.md +++ b/guide/architecture.md @@ -32,7 +32,7 @@ nav_order: 4 | 화면 | 할 수 있는 일 | | --- | --- | -| Live · `MainActivity` | 앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며 지원 카메라의 Depth 맵을 봅니다. | +| Live · `MainActivity` | 앱을 열면 나오는 화면입니다. 프리뷰를 보고 사진·동영상을 촬영하며, 지원 카메라의 제조사 Portrait와 Depth 맵을 선택합니다. | | Multi · `ConcurrentCameraActivity` | 독립 카메라 장치별 사진·영상을 제공합니다. Live의 Multi · P와 Multi · V에서 엽니다. | | Lab · `WorkbenchActivity` | 기기 정보, 검사 도구, 저장된 결과와 설정을 엽니다. | | Benchmark · `BenchmarkActivity` | 정해진 조건(profile)으로 측정하고 결과를 저장합니다. | @@ -57,7 +57,7 @@ PC에서 같은 기능을 실행하려면 [CLI](cli.md)를 사용합니다. `Cli - 근거 파일: `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `tools/halcam/halcam/cli.py`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt`, `app/src/main/java/dev/halcamera/WorkbenchActivity.kt`, `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review @@ -136,6 +136,7 @@ flowchart TB camera/ - 엔진 계약, Camera2·CameraX 구현, 엔드포인트 열거를 제공합니다. `DepthCameraEngine`은 컬러 출력 없이 DEPTH16 맵만 받으며 `DepthFrame`이 순수 Kotlin으로 표시용 표본을 만듭니다. +- `PortraitCameraEngine`은 LIVE의 제조사 BOKEH 전용 세션입니다. MainPortraitBridge가 JPEG 크기를 선택하고 MainDepthBridge가 Depth Map과 Portrait의 진입점을 구분합니다. 이 경로는 벤치마크를 실행하거나 센서 Depth 맵을 저장하지 않습니다. - `close(done)` 완료 전에 다음 카메라를 열지 않습니다. - 엔진 계약(`MediaCapture`, `LiveTuning`, `TouchMetering`)과 두 엔진의 구현, 두 엔진의 차이는 [Engine Comparison](engine.md)에 있습니다. - 벤치마크 RECORD 단계의 recorder 상태 기계는 `BenchmarkRecorder`가 담당하고 `Camera2Engine`은 그 호출을 위임합니다. @@ -250,9 +251,9 @@ Camera2의 RAW (DNG)는 `RawFrame`이 RAW 샘플을 복사하고 `DngOutput`이
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/SplitDragGesture.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `app/src/main/java/dev/halcamera/cli/LiveController.kt`, `app/src/main/java/dev/halcamera/cli/BenchmarkController.kt`, `app/src/main/java/dev/halcamera/cli/CliLibrary.kt`, `app/src/main/java/dev/halcamera/MainCliBridge.kt`, `app/src/main/java/dev/halcamera/DualCliBridge.kt`, `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/EnvironmentProbe.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunIndex.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkCsv.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt`, `app/src/main/java/dev/halcamera/ui/RecentMediaButton.kt`, `app/src/main/java/dev/halcamera/ui/Look.kt`, `app/src/main/java/dev/halcamera/cts/recording/BasicRecordingRules.kt`, `app/src/main/java/dev/halcamera/cts/CtsEntryActivity.kt`, `app/src/main/java/dev/halcamera/cts/vendored/VendoredCaseActivity.kt`, `app/src/main/java/dev/halcamera/cts/vendored/VendoredCtsListActivity.kt`, `app/src/main/java/dev/halcamera/cts/CtsCaseActivity.kt`, `app/src/main/java/dev/halcamera/cts/CtsCaseListActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/CtsChecklistActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/CtsSuiteRunActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/SuitePlan.kt`, `app/src/main/java/dev/halcamera/cts/suite/SuiteReport.kt`, `app/src/main/java/dev/halcamera/cts/CtsCatalog.kt`, `app/src/main/java/dev/halcamera/cts/CtsRunner.kt`, `app/src/main/java/dev/halcamera/cts/CameraCaseRunner.kt`, `app/src/main/java/dev/halcamera/cts/Camera2Ops.kt`, `app/src/main/java/dev/halcamera/cts/onoff/FastOnOffRules.kt`, `app/src/main/java/dev/halcamera/cts/switching/SwitchingRules.kt`, `app/src/main/java/dev/halcamera/cts/sizes/AllSizeOnOffRules.kt`, `app/src/main/java/dev/halcamera/cts/combination/StillPreviewCombinationRules.kt`, `app/src/main/java/dev/halcamera/cts/snapshot/VideoSnapshotRules.kt`, `app/src/main/java/dev/halcamera/CameraProbeActivity.kt`, `app/src/main/java/dev/halcamera/camera/CameraProbe.kt`, `app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/SplitDragGesture.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `app/src/main/java/dev/halcamera/cli/LiveController.kt`, `app/src/main/java/dev/halcamera/cli/BenchmarkController.kt`, `app/src/main/java/dev/halcamera/cli/CliLibrary.kt`, `app/src/main/java/dev/halcamera/MainCliBridge.kt`, `app/src/main/java/dev/halcamera/DualCliBridge.kt`, `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/BenchmarkRecorder.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/EnvironmentProbe.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunIndex.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkCsv.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/ConcurrentCameraActivity.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt`, `app/src/main/java/dev/halcamera/ui/RecentMediaButton.kt`, `app/src/main/java/dev/halcamera/ui/Look.kt`, `app/src/main/java/dev/halcamera/cts/recording/BasicRecordingRules.kt`, `app/src/main/java/dev/halcamera/cts/CtsEntryActivity.kt`, `app/src/main/java/dev/halcamera/cts/vendored/VendoredCaseActivity.kt`, `app/src/main/java/dev/halcamera/cts/vendored/VendoredCtsListActivity.kt`, `app/src/main/java/dev/halcamera/cts/CtsCaseActivity.kt`, `app/src/main/java/dev/halcamera/cts/CtsCaseListActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/CtsChecklistActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/CtsSuiteRunActivity.kt`, `app/src/main/java/dev/halcamera/cts/suite/SuitePlan.kt`, `app/src/main/java/dev/halcamera/cts/suite/SuiteReport.kt`, `app/src/main/java/dev/halcamera/cts/CtsCatalog.kt`, `app/src/main/java/dev/halcamera/cts/CtsRunner.kt`, `app/src/main/java/dev/halcamera/cts/CameraCaseRunner.kt`, `app/src/main/java/dev/halcamera/cts/Camera2Ops.kt`, `app/src/main/java/dev/halcamera/cts/onoff/FastOnOffRules.kt`, `app/src/main/java/dev/halcamera/cts/switching/SwitchingRules.kt`, `app/src/main/java/dev/halcamera/cts/sizes/AllSizeOnOffRules.kt`, `app/src/main/java/dev/halcamera/cts/combination/StillPreviewCombinationRules.kt`, `app/src/main/java/dev/halcamera/cts/snapshot/VideoSnapshotRules.kt`, `app/src/main/java/dev/halcamera/CameraProbeActivity.kt`, `app/src/main/java/dev/halcamera/camera/CameraProbe.kt`, `app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -292,6 +293,8 @@ Live 셔터 조작은 `MainActivity`에서 선택한 엔진의 촬영·녹화 Live의 Depth 보기는 `DepthCameraEngine → ImageReader → DepthFrame → DepthMapView`로 연결합니다. 컬러 촬영과 분리된 보기이며, Telemetry에는 버퍼의 시각·크기·형식과 capture 콜백만 기록합니다. 맵 픽셀을 벤치마크나 incident ZIP의 입력으로 전달하지 않습니다. 사용 순서는 [Depth 맵 보기](live.md#depth-맵을-보세요)에 있습니다. +같은 Depth 메뉴의 Portrait는 `PortraitCameraEngine → CameraExtensionSession → JPEG ImageReader → MediaLibrary`로 연결합니다. 제조사 Bokeh가 적용된 컬러 사진을 저장하며 별도의 센서 Depth 맵을 받지 않습니다. 확장 처리 이벤트는 일반 Camera2 프레임 지표와 구분하고, 사진 픽셀은 측정 파일에 넣지 않습니다. + 벤치마크는 별도 화면인 `BenchmarkActivity`가 준비를 마친 뒤 `BenchmarkRunner.start()`를 호출하여 시작합니다. `Telemetry.callback(...)`이 반환한 Camera2 콜백의 `onCaptureStarted`는 프레임워크 신호를 기록하며, Live 셔터와 벤치마크 시작을 연결하는 메서드가 아닙니다. @@ -471,9 +474,9 @@ CliLaunchActivity는 Dual·Multi에서 Live로 돌아갈 때 CliNavigationHost
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/cli/CliProvider.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `tools/halcam/halcam/cli.py`, `tools/halcam/halcam/download.py`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/GalleryActivity.kt`, `app/src/main/java/dev/halcamera/camera/MediaLibrary.kt`, `app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/metrics/MetricExtractor.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunRetention.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/LaunchDiagnostics.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/cli/CliProvider.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `tools/halcam/halcam/cli.py`, `tools/halcam/halcam/download.py`, `app/src/main/java/dev/halcamera/MainActivity.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/GalleryActivity.kt`, `app/src/main/java/dev/halcamera/camera/MediaLibrary.kt`, `app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt`, `app/src/main/java/dev/halcamera/telemetry/Telemetry.kt`, `app/src/main/java/dev/halcamera/telemetry/FlightRecorder.kt`, `app/src/main/java/dev/halcamera/metrics/MetricExtractor.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunAssembler.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunRetention.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt`, `app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/LaunchDiagnostics.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RegressionDetector.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -643,7 +646,7 @@ sequenceDiagram Live 프리뷰에서 두 손가락을 벌리거나 모으면 지원 범위 안에서 연속으로 줌을 바꿉니다. 가장 가까운 프리셋의 원에 현재 배율을 소수 첫째 자리까지 표시하며, 펼친 상태에서 그 원을 누르면 해당 프리셋 배율로 돌아갑니다. 화면의 카메라 선택 정보를 갱신해도 중간 배율은 유지합니다. -핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. +핀치와 버튼 선택은 같은 MainActivity.applyZoom 경로로 엔진과 표시를 갱신합니다. Depth 보기·제조사 Portrait, 프리뷰 준비 전, 일시정지·종료 처리·CLI 작업·연사 중에는 변경하지 않으며 녹화 중에는 허용합니다. 두 손가락 입력이 시작되면 대기 중인 터치 초점과 길게 누르기 인식을 취소하고, 마지막 손가락을 뗄 때까지 초점 터치로 처리하지 않습니다. | 조건 | 동작 | | --- | --- | @@ -687,6 +690,8 @@ Photo·Video 프리뷰와 녹화 중에 두 손가락을 벌리거나 모아 연 Depth 보기는 컬러와 Depth를 같은 요청에 포함하지 않는 별도 Camera2 세션입니다. HAL의 동시 요청 허용 선언은 특정 출력 조합의 성공을 보장하지 않습니다. `DepthCameraEngine`은 촬영·녹화·수동 제어·줌·터치 측광을 제공하지 않으며 CLI 제어 변경도 거절합니다. Photo·Video 또는 CLI 촬영 준비로 전환하면 Depth를 해제합니다. +제조사 Portrait도 일반 촬영과 별도의 확장 세션을 사용합니다. Bokeh 확장 전용 해상도로 컬러 미리보기와 JPEG만 요청하며, 세션이 거절되면 다른 크기로 자동 대체하지 않습니다. 단일 촬영의 JPEG를 받기 전에 실패하거나 종료되면 세션을 해제하여 늦은 이미지를 다음 촬영에 연결하지 않습니다. 이미 받은 JPEG의 저장 작업은 종료 후에도 마칠 수 있습니다. + Multi 모드의 `MultiSession`은 `CameraEngine`과 별도입니다. Camera2는 선택한 독립 장치를 연 뒤 세션을 구성하며, CameraX는 광고된 동시 조합을 바인딩합니다. 모든 장치의 종료 통지를 확인해야 출력을 해제하고 다음 동시 세션을 열 수 있습니다. 한 명령의 여러 사진을 센서 동기 촬영으로 해석하지 않습니다. @@ -744,9 +749,9 @@ Live 카메라 종료 후 벤치마크 화면으로 이동하고 기존 측정·
    근거와 검토 정보 -- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/MicrophoneCapture.kt`, `app/src/main/java/dev/halcamera/camera/TimestampedVideoRecording.kt`, `app/src/main/java/dev/halcamera/camera/DeviceCompositor.kt`, `app/src/main/java/dev/halcamera/cli/CliProvider.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `app/src/main/java/dev/halcamera/cli/CommandStore.kt`, `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraEndpointResolver.kt`, `app/src/main/java/dev/halcamera/telemetry/IncidentExporter.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RegressionRules.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkStore.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkIndex.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/AtomicFiles.kt` +- 근거 파일: `app/src/main/java/dev/halcamera/camera/MultiSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraXMultiSession.kt`, `app/src/main/java/dev/halcamera/camera/MultiRecording.kt`, `app/src/main/java/dev/halcamera/camera/MicrophoneCapture.kt`, `app/src/main/java/dev/halcamera/camera/TimestampedVideoRecording.kt`, `app/src/main/java/dev/halcamera/camera/DeviceCompositor.kt`, `app/src/main/java/dev/halcamera/cli/CliProvider.kt`, `app/src/main/java/dev/halcamera/cli/CommandCoordinator.kt`, `app/src/main/java/dev/halcamera/cli/CommandStore.kt`, `app/src/main/java/dev/halcamera/camera/CameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt`, `app/src/main/java/dev/halcamera/camera/ConcurrentSession.kt`, `app/src/main/java/dev/halcamera/camera/CameraEndpointResolver.kt`, `app/src/main/java/dev/halcamera/telemetry/IncidentExporter.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkRunner.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/RegressionRules.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkReport.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkReportCodec.kt`, `app/src/main/java/dev/halcamera/benchmark/platform/BenchmarkStore.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkIndex.kt`, `app/src/main/java/dev/halcamera/benchmark/domain/AtomicFiles.kt` - 근거 수준: 코드 확인 -- 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review +- 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review
    @@ -798,16 +803,16 @@ Android 의존성이 없는 러너와 평가 로직은 JVM 단위 테스트로 | 항목 | 최신성 | 검토 | | --- | --- | --- | -| 구조 원본 `data-flow` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 구조 원본 `state-transitions` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | +| 구조 원본 `data-flow` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 구조 원본 `overall-architecture` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 구조 원본 `state-transitions` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | | 구조 원본 `ui-camera-label` | 최신 | 검토 2026-10-10 @ `93385443` · Codex (mode reset, labels and numeric ID selection review) | -| 구조 원본 `ui-tool-handoff` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 구조 원본 `ui-zoom` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 원고 `overview` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `module-roles` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | -| 원고 `runtime-flow` | 최신 | 검토 2026-10-19 @ `14ca1ae2` · codex-user-authorized-depth-review | -| 원고 `constraints` | 최신 | 검토 2026-10-19 @ `b0648a05` · codex-user-authorized-depth-review | +| 구조 원본 `ui-tool-handoff` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 구조 원본 `ui-zoom` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `overview` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `module-roles` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `runtime-flow` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | +| 원고 `constraints` | 최신 | 검토 2026-10-19 @ `2fb9f2aa` · codex-user-authorized-portrait-review | diff --git a/guide/live.md b/guide/live.md index d6b3a5a7..87d6daaa 100644 --- a/guide/live.md +++ b/guide/live.md @@ -8,6 +8,7 @@ title: Live Photo로 한 장을 찍고, Video로 움직임을 담아 보세요. 처음에는 기본값이면 충분합니다. - **[촬영](#live에서-촬영하세요)** · 사진·연사·AEB·동영상을 촬영합니다. +- **[Portrait](#제조사-portrait로-배경을-흐리세요)** · Depth 메뉴에서 제조사 Bokeh 사진을 선택합니다. - **[수동 설정](#수동-촬영-조건을-고정하세요)** · ISO·노출 시간·초점을 고정합니다. - **[스트림 설정](#live-스트림을-설정하세요)** · 출력 크기·FPS·포맷을 바꿉니다. - **[PIP](#pip로-보이는-구도를-저장하세요) · [Multi](#여러-카메라를-함께-촬영하세요)** · 보조 화면을 합성하거나 여러 카메라로 촬영합니다. @@ -25,11 +26,27 @@ Photo로 한 장을 찍고, Video로 움직임을 담아 보세요. 처음에는 **모드나 엔진을 바꾸면 새 촬영으로 시작합니다.** PIP·스트림·줌·촬영 제어는 초기화됩니다. Callback의 고정 시간은 사용자 설정으로 유지합니다. +### 제조사 Portrait로 배경을 흐리세요 + +1. `Lab → Probe`에서 카메라별 `Portrait · Manufacturer Bokeh` 지원 여부와 전용 미리보기·JPEG 해상도를 확인합니다. +2. Live의 `Depth → Portrait (Bokeh)`를 선택합니다. +3. JPEG 해상도를 고릅니다. 각 항목에 함께 구성할 미리보기 해상도를 표시합니다. +4. 미리보기가 준비되면 가운데 셔터로 촬영하고, 저장 완료 뒤 갤러리에서 JPEG를 확인합니다. +5. 일반 촬영으로 돌아가려면 `Photo`나 `Video`를 누릅니다. `Depth → Off`로도 Photo로 돌아갑니다. + +**Portrait는 제조사가 처리한 컬러 미리보기와 JPEG를 받는 촬영 모드입니다.** Depth 메뉴에서 선택하지만 센서 Depth 맵을 받는 기능은 아닙니다. `DEPTH_OUTPUT`이 없는 카메라도 Bokeh 확장을 제공할 수 있습니다. 이 앱의 제조사 Portrait는 Android 12 / API 31 이상에서 Camera2 확장 세션을 사용합니다. + +선택하면 기존 카메라를 닫고 표시한 해상도로 세션을 엽니다. 나열된 크기라도 조합에 따라 제조사가 거절할 수 있으며, 다른 크기로 자동 대체하지 않습니다. 세션 구성 후 첫 미리보기 프레임은 15초, 촬영 요청 후 JPEG 수신은 60초까지 기다리고 실패 이유를 표시합니다. 실패 뒤에는 Depth 메뉴에서 해상도를 다시 고르거나 Photo로 돌아가세요. + +Portrait에서는 동영상·RAW·YUV 사진·연사·AEB·PIP·줌·터치 측광·수동 제어를 제공하지 않습니다. 배경 흐림의 모양과 강도는 제조사가 정합니다. JPEG와 `manufacturer_bokeh` 촬영 메타데이터는 일반 사진과 같은 저장 경로에 남고 Benchmark JSON이나 진단 ZIP에 이미지 픽셀을 넣지 않습니다. + +같은 모드로 Lab에서 돌아오거나 앱을 다시 표시하면 Portrait 해상도를 유지합니다. 카메라·엔진을 바꾸거나 일반 Live Streams 설정을 적용하면 Portrait를 종료합니다. 크기 표시는 일반 Live Streams 대신 Depth 선택 메뉴를 엽니다. 새 CLI 촬영 요청은 일반 Photo·Video 구성을 사용하며 Portrait를 이어받지 않습니다. + ### Depth 맵을 보세요 1. `Lab → Probe`에서 카메라의 `Depth` 섹션을 확인합니다. 2. Live로 돌아와 카메라를 선택합니다. -3. 하단의 `Depth`를 누르고 `DEPTH16` 해상도를 선택합니다. 지원하지 않거나 정보를 읽지 못한 카메라는 이유를 표시합니다. +3. 하단의 `Depth → Depth Map`에서 `DEPTH16` 해상도를 선택합니다. 지원하지 않거나 정보를 읽지 못한 카메라는 Depth 메뉴에 이유를 표시합니다. 4. 컬러 화면으로 돌아가려면 `Photo` 또는 `Video`를 누릅니다. `Depth → Off`로도 Photo로 돌아갈 수 있습니다. **Depth는 Camera2의 깊이 맵만 표시하는 보기 모드입니다.** 선택하면 기존 카메라를 닫은 뒤 Depth 전용 세션을 엽니다. 컬러와 Depth를 같은 요청에서 받을 수 없는 기기에서도 사용할 수 있으며, 컬러와 정렬된 합성 화면은 제공하지 않습니다. 사진·동영상 저장, 줌·터치 측광·수동 제어와 PIP는 사용할 수 없습니다. diff --git a/guide/probe.md b/guide/probe.md index bb9c4321..514fe8e2 100644 --- a/guide/probe.md +++ b/guide/probe.md @@ -30,12 +30,13 @@ title: Probe - **섹션:** 제목을 눌러 접거나 펼칩니다. 출력 형식별 크기는 STREAMS에서 확인합니다. - **DEPTH:** `DEPTH_OUTPUT` 지원 여부와 `DEPTH16` 해상도, 컬러와 Depth를 같은 요청에 포함할 수 있는지를 확인합니다. `Allowed by HAL`은 기기의 선언이며 특정 출력 조합의 동작을 보장하지 않습니다. `Unknown`은 해당 정보를 읽지 못한 상태입니다. 단독으로 열 수 없는 물리 카메라는 LIVE에서 직접 선택할 수 없습니다. - **필터:** 단어가 들어간 줄을 찾습니다. 결과를 누르면 해당 항목으로 이동합니다. +- **PORTRAIT · MANUFACTURER BOKEH:** 제조사 Bokeh 지원 여부와 확장 전용 미리보기·JPEG 해상도를 확인합니다. 일반 STREAMS 크기와 구분하며, 크기 조합의 성공 여부는 실제 촬영으로 확인해야 합니다. Android 12 미만이나 단독 선택할 수 없는 물리 카메라는 이유를 표시합니다. Bokeh 지원은 Depth 맵 공개를 뜻하지 않습니다. - **REQUEST · RESULT KEYS / SESSION KEYS:** 요청·결과에서 지원하는 key 이름입니다. 실제 촬영값은 아닙니다.
    섹션 이름과 API별 표시 조건 -IDENTITY, CAPABILITIES, DEPTH, SENSOR, LENS, CONTROL, PROCESSING, REQUEST 뒤에 REQUEST · RESULT KEYS가 나옵니다. +IDENTITY, CAPABILITIES, DEPTH, PORTRAIT · MANUFACTURER BOKEH, SENSOR, LENS, CONTROL, PROCESSING, REQUEST 뒤에 REQUEST · RESULT KEYS가 나옵니다. SESSION KEYS는 API 28 이상, MANDATORY STREAM COMBINATIONS는 API 29 이상에서 표시합니다. diff --git a/tools/docgen/regression.test.mjs b/tools/docgen/regression.test.mjs index e92f96ab..fadd9a57 100644 --- a/tools/docgen/regression.test.mjs +++ b/tools/docgen/regression.test.mjs @@ -114,7 +114,7 @@ test('all configured elements fit the prompt budget and exclude descendant field assert.ok(input.files.length > 0); count++; } } - assert.equal(count, 112); // Includes the LIVE depth engine's own evidence. + assert.equal(count, 115); // Depth/Portrait engines, Portrait lifecycle and LIVE camera picker own their evidence. `)); }); diff --git a/tools/docgen/state/evidence.json b/tools/docgen/state/evidence.json index 35173d0e..9a039687 100644 --- a/tools/docgen/state/evidence.json +++ b/tools/docgen/state/evidence.json @@ -6,11 +6,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "0f722523cc241bb1", + "codeHash": "057f692f5c7ba25d", + "modelHash": "a911638bf6e3cbc2", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -20,9 +20,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -76,7 +78,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -150,6 +152,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -255,13 +260,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -291,14 +296,14 @@ "state": "fresh" }, "accepted": { - "codeHash": "97b991ec49bfd746", - "modelHash": "9dbb60c0c18065a2", + "codeHash": "b415ef0c3c1996d6", + "modelHash": "7c9aaf4a651758f8", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", "app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt": "fc28cf52692a271225524bfa045dc2e232d0ba45", "app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt": "3ce60c16472f2ed3e100034c62feee32f13236d3", @@ -342,13 +347,15 @@ "state": "fresh" }, "accepted": { - "codeHash": "47bddf1bbf31016c", - "modelHash": "258630c74fc0a4f4", + "codeHash": "f98fe1d594f993e7", + "modelHash": "d4ee6d79890b29f2", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", "app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt": "fc28cf52692a271225524bfa045dc2e232d0ba45", "app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt": "3ce60c16472f2ed3e100034c62feee32f13236d3", @@ -358,6 +365,7 @@ "app/src/main/java/dev/halcamera/benchmark/domain/RunValidity.kt": "df32b7ff015c81cb44407884ea02deda965752df", "app/src/main/java/dev/halcamera/camera/Camera2Engine.kt": "a07b50648bd1812a3ae55e1b31494e963feb060c", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", "app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt": "c8ca57c719ca0df2bc002493a1568c3de1196390", "app/src/main/java/dev/halcamera/cli/BenchmarkController.kt": "1789489c62871f8bb25a4274e76c58c80414182f", "app/src/main/java/dev/halcamera/cli/CliCommand.kt": "590a408fec3e4c8fb08a9b0a3ca50efb2b10e609", @@ -386,11 +394,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "19ab0340fcab873e", - "modelHash": "66bd16d22ce9ca3f", + "codeHash": "37a6ba8e14f48c93", + "modelHash": "c04a5640113b0575", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -400,9 +408,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -456,7 +466,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -530,6 +540,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -635,13 +648,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -674,14 +687,14 @@ "state": "fresh" }, "accepted": { - "codeHash": "1bbf79a32baf27f3", - "modelHash": "2f360beb8b83bf25", + "codeHash": "56197667ece1fbbb", + "modelHash": "4cdc084d8ed49bb6", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", "app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt": "fc28cf52692a271225524bfa045dc2e232d0ba45", "app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt": "3ce60c16472f2ed3e100034c62feee32f13236d3", @@ -703,6 +716,7 @@ "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", "app/src/main/java/dev/halcamera/camera/MediaLibrary.kt": "2fbb56c9e81761cea93f64109ec12497309cffe8", "app/src/main/java/dev/halcamera/camera/MediaTransaction.kt": "34f0121f9c418c8e49fb0326a99882ae6f447c94", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", "app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt": "c8ca57c719ca0df2bc002493a1568c3de1196390", "app/src/main/java/dev/halcamera/cli/BenchmarkController.kt": "1789489c62871f8bb25a4274e76c58c80414182f", "app/src/main/java/dev/halcamera/cli/CliArtifactRegistry.kt": "e01a37879d2b3638a95bcb2a54adf75ab4643ac8", @@ -723,11 +737,11 @@ }, "content:architecture.md/module-roles": { "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "8ffefe5ee952f837", + "codeHash": "057f692f5c7ba25d", + "modelHash": "9f3d1f8b2ae8dba5", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -737,9 +751,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -793,7 +809,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -867,6 +883,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -972,13 +991,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -1008,11 +1027,11 @@ }, "content:architecture.md/constraints": { "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "90c7c730cb5e8337", + "codeHash": "057f692f5c7ba25d", + "modelHash": "bdd9eb84b2f3c403", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -1022,9 +1041,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -1078,7 +1099,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -1152,6 +1173,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -1257,13 +1281,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -1312,14 +1336,14 @@ "state": "fresh" }, "accepted": { - "codeHash": "5733479109e046e8", - "modelHash": "9d479f2b55b0b1b7", + "codeHash": "af533493626cd705", + "modelHash": "a27ff87e34cf0c58", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da" + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2" } } }, @@ -1328,13 +1352,14 @@ "state": "fresh" }, "accepted": { - "codeHash": "0c74536c0bae608f", - "modelHash": "231d9c4ddb3622f9", + "codeHash": "9142002fc98a5ef3", + "modelHash": "b0bccaebbd551910", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", "app/src/main/java/dev/halcamera/ui/ExpandingZoomControl.kt": "e93a1cc7727b9200fa6aaf31d8210050a0b6acf9", "app/src/main/java/dev/halcamera/ui/FocusRing.kt": "e129ce223edde62c329a15b5662fa34f9f6a6918" } @@ -1345,11 +1370,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "a882f52b4155cd27", + "codeHash": "057f692f5c7ba25d", + "modelHash": "f6d90c9bafc4d997", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -1359,9 +1384,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -1415,7 +1442,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -1489,6 +1516,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -1594,13 +1624,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -1630,11 +1660,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "a1c4cf8671b1c8c4", + "codeHash": "057f692f5c7ba25d", + "modelHash": "75f06da9844aed35", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -1644,9 +1674,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -1700,7 +1732,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -1774,6 +1806,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -1879,13 +1914,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -1915,11 +1950,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "ab1f7e43500f5869", + "codeHash": "057f692f5c7ba25d", + "modelHash": "ccd050f4375de1c0", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -1929,9 +1964,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -1985,7 +2022,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -2059,6 +2096,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -2164,13 +2204,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -2200,11 +2240,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "1ac54c16fae61b66", + "codeHash": "057f692f5c7ba25d", + "modelHash": "7b83fe41e96866ee", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -2214,9 +2254,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -2270,7 +2312,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -2344,6 +2386,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -2449,13 +2494,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b", @@ -2485,14 +2530,16 @@ "state": "fresh" }, "accepted": { - "codeHash": "9edb9935832a8bbc", - "modelHash": "20708f2ee142c5a7", + "codeHash": "c7377f64a346f1ea", + "modelHash": "cbaa131e4b682933", "at": "2026-10-19", - "commit": "14ca1ae2", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", "app/src/main/java/dev/halcamera/benchmark/HistoryActivity.kt": "fc28cf52692a271225524bfa045dc2e232d0ba45", "app/src/main/java/dev/halcamera/benchmark/domain/BenchmarkEvaluator.kt": "3ce60c16472f2ed3e100034c62feee32f13236d3", @@ -2515,6 +2562,7 @@ "app/src/main/java/dev/halcamera/camera/ManualControlRequests.kt": "a3c9c783fc821e641b5b2096bd8fb53e7f00e695", "app/src/main/java/dev/halcamera/camera/MediaLibrary.kt": "2fbb56c9e81761cea93f64109ec12497309cffe8", "app/src/main/java/dev/halcamera/camera/MediaTransaction.kt": "34f0121f9c418c8e49fb0326a99882ae6f447c94", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", "app/src/main/java/dev/halcamera/camera/RecentMediaThumbnail.kt": "c8ca57c719ca0df2bc002493a1568c3de1196390", "app/src/main/java/dev/halcamera/cli/BenchmarkController.kt": "1789489c62871f8bb25a4274e76c58c80414182f", "app/src/main/java/dev/halcamera/cli/CliArtifactRegistry.kt": "e01a37879d2b3638a95bcb2a54adf75ab4643ac8", @@ -2547,11 +2595,11 @@ "state": "fresh" }, "accepted": { - "codeHash": "c896f57e566251f5", - "modelHash": "ab3da8834574c341", + "codeHash": "057f692f5c7ba25d", + "modelHash": "5ce9361a6565a237", "at": "2026-10-19", - "commit": "b0648a05", - "reviewer": "codex-user-authorized-depth-review", + "commit": "2fb9f2aa", + "reviewer": "codex-user-authorized-portrait-review", "files": { "app/build.gradle.kts": "d66d0773b7c3040674518d8a9db04d40fd67ee57", "app/src/main/AndroidManifest.xml": "394f1ceb67558a1400f691c3eb3157f7be783960", @@ -2561,9 +2609,11 @@ "app/src/main/java/dev/halcamera/DualPreviewActivity.kt": "93bf6e4bb3e10601631adcf12824164a06dc990e", "app/src/main/java/dev/halcamera/GalleryActivity.kt": "d220302a8e9dd392cf1560e5b28dc5aa6378202f", "app/src/main/java/dev/halcamera/LiveStreamsActivity.kt": "71377b456162e55482645967b3d5b22260d91223", - "app/src/main/java/dev/halcamera/MainActivity.kt": "0fa42b6713f8b20632ec77a6b4afd57cf3f80ebb", - "app/src/main/java/dev/halcamera/MainCliBridge.kt": "fde7bf3ae0a6f1a451fa84718a37d90052efd6da", - "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1d8d7f6f32f0b2feaa59c279bb0d0a31481acf4b", + "app/src/main/java/dev/halcamera/MainActivity.kt": "4e4d7b9b7383b8b866db5942ff1e9de6b5dd0f8e", + "app/src/main/java/dev/halcamera/MainCameraPicker.kt": "875268d3943a2ede5659580905ba787a934fb1dc", + "app/src/main/java/dev/halcamera/MainCliBridge.kt": "f50d07599ff67f5718f285d7903197516ab0e5a2", + "app/src/main/java/dev/halcamera/MainDepthBridge.kt": "1ce3bb1f2264e8a7d8e920c9aea6b6f67db14825", + "app/src/main/java/dev/halcamera/MainPortraitBridge.kt": "d5a840effb2748fd3c1f027396eed92078886159", "app/src/main/java/dev/halcamera/MultiCliBridge.kt": "52a5825ca1ec9d495a6ab76f5738bb5e1d7a1004", "app/src/main/java/dev/halcamera/WorkbenchActivity.kt": "f9c6c4c5e045c737e5375f8c3c2ec283b6381891", "app/src/main/java/dev/halcamera/benchmark/BenchmarkActivity.kt": "2c93630441feea87cc093dd8aed8384cf45956f1", @@ -2617,7 +2667,7 @@ "app/src/main/java/dev/halcamera/camera/CameraEngine.kt": "7441e69d8ef8394db42448a69abd9fa688167c70", "app/src/main/java/dev/halcamera/camera/CameraOpenRetry.kt": "65d85b90a24ff613324cceb838f963028e1d5daa", "app/src/main/java/dev/halcamera/camera/CameraProbe.kt": "c7bc7ce608c811830e4a88e913e0593ad15b0528", - "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "5da08af7e2f1b94dc6bf09fd3f94f01fed86c7f9", + "app/src/main/java/dev/halcamera/camera/CameraProbeReader.kt": "621409ea83f6aa108f525c83a5b151a1ef58fe48", "app/src/main/java/dev/halcamera/camera/CameraReleaseWait.kt": "cd2fb3f441229ac0e4a4d4f09ce7b27de5e64d4e", "app/src/main/java/dev/halcamera/camera/CameraXControls.kt": "2bdcc32c326a7685cb34ac5a59f2665cce940fc8", "app/src/main/java/dev/halcamera/camera/CameraXEngine.kt": "c885e0bb38e481d45fda923dbea1d03e60a1a2b7", @@ -2691,6 +2741,9 @@ "app/src/main/java/dev/halcamera/camera/PipSource.kt": "ea1230c627c8eb59be2a2a96ff1de428b017b14d", "app/src/main/java/dev/halcamera/camera/PipStreamCapabilities.kt": "6fbe202969c6ff6d26f8a1d5e0299d2871aadd68", "app/src/main/java/dev/halcamera/camera/PipStreamSettings.kt": "873228820c0867a3d6ffd31dde1a353c26d8a771", + "app/src/main/java/dev/halcamera/camera/PortraitCameraEngine.kt": "487943447e62e1f101687a0ebc3967abb78a54a3", + "app/src/main/java/dev/halcamera/camera/PortraitCapabilities.kt": "7c843f4430f9292bada4f67932c436f9e465b53f", + "app/src/main/java/dev/halcamera/camera/PortraitSupport.kt": "d2b678ccdfd271d3e6051f71b8909f53c342da9d", "app/src/main/java/dev/halcamera/camera/PreviewBufferRelay.kt": "135eb236cdc8054d2d1ad89b703c7a6975eff44b", "app/src/main/java/dev/halcamera/camera/PreviewTransform.kt": "35ce0844884671a3848d6d87356cfb6635775c11", "app/src/main/java/dev/halcamera/camera/RawFrame.kt": "0809991e5c0aadc75e4cbfadfdf8257fc0a1e779", @@ -2796,13 +2849,13 @@ "app/src/main/java/dev/halcamera/ui/LabDialog.kt": "cecb9e63383748663a8650362588ecdef9024766", "app/src/main/java/dev/halcamera/ui/LiveChoiceSheet.kt": "08eee11b7e9f3b1f499697c27907fa1d77b2f95c", "app/src/main/java/dev/halcamera/ui/LiveControlBar.kt": "f10adc3e8ff30d0a7af97748468169e338144ba8", - "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "1ea58d787b35c1b1b21dcfcccc6e1b3272b6ca22", + "app/src/main/java/dev/halcamera/ui/LiveIndicator.kt": "218318c32d81fbdca64c39e2d71e1cda07aea2ba", "app/src/main/java/dev/halcamera/ui/LiveMeasurementText.kt": "f634eb1f4f342c29e823365255ad8263ff7ea1b5", "app/src/main/java/dev/halcamera/ui/LiveMeasurementView.kt": "3b0e5f244816f6844c08208adb738c42d00a5a06", "app/src/main/java/dev/halcamera/ui/LivePipController.kt": "7f706fae51c274c351cee16032a7d1b300563828", "app/src/main/java/dev/halcamera/ui/LiveReadings.kt": "5fb091d0da47c97dee10c62d79492b83c382bf3e", "app/src/main/java/dev/halcamera/ui/LiveReadout.kt": "3f599e31c9eecd3f48c7467632c1c948f02bbdfc", - "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "976ee52adcfec7b08dc0571ff9bdde7d36e1f72a", + "app/src/main/java/dev/halcamera/ui/LiveStreamReadout.kt": "96cebd17ced1dab82a4bf81a7f9843fc4ffded95", "app/src/main/java/dev/halcamera/ui/LiveStreamSettingsView.kt": "d47f8595645c0af61d3bc088c0b6f6773de6dd8d", "app/src/main/java/dev/halcamera/ui/Look.kt": "51326eb313529537407c10dae5e721eb153a9f53", "app/src/main/java/dev/halcamera/ui/ManualControlPanel.kt": "baa1572bd2a5db79b016d066a6d5b5d1105a326b",