From 2db4ef1c353591b26a1648512e1d76c38dd7b8c8 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 3 Aug 2026 21:13:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(imagepicker):=20=ED=81=AC=EB=A1=AD=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=B3=B4=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 6 + .../ImagePicker/ImageCropGeometry.swift | 15 + .../Sources/ImagePicker/ImagePicker.swift | 40 ++- .../plan-task.md | 272 ++++++++++++++++++ docs/20260803_이미지_크롭_범위_보정/prd.md | 84 ++++++ work/tests/ImageCropGeometryCheck.swift | 40 +++ 6 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 SodaLive/Sources/ImagePicker/ImageCropGeometry.swift create mode 100644 docs/20260803_이미지_크롭_범위_보정/plan-task.md create mode 100644 docs/20260803_이미지_크롭_범위_보정/prd.md create mode 100644 work/tests/ImageCropGeometryCheck.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index d6f4e604..df39f393 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -1077,6 +1077,8 @@ 40B28AB12A839E59008BE463 /* DetailNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */; }; 40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; 40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; + 7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; }; + 7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; }; 40B37EE82A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EE92A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */; }; @@ -2644,6 +2646,7 @@ 40B28AAC2A839DE4008BE463 /* BaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = ""; }; 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailNavigationBar.swift; sourceTree = ""; }; 40B28AB32A839E9D008BE463 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCropGeometry.swift; sourceTree = ""; }; 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateView.swift; sourceTree = ""; }; 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateViewModel.swift; sourceTree = ""; }; 40B37EF02A90A484000DE782 /* ModifyPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyPasswordView.swift; sourceTree = ""; }; @@ -4598,6 +4601,7 @@ isa = PBXGroup; children = ( 40B28AB32A839E9D008BE463 /* ImagePicker.swift */, + 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */, ); path = ImagePicker; sourceTree = ""; @@ -7121,6 +7125,7 @@ 4091440E2A85C90600EEBFB1 /* UserProfileViewModel.swift in Sources */, 409143E92A85A7FE00EEBFB1 /* ContentCreateView.swift in Sources */, 40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */, + 7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */, 409868562DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */, 409143D22A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */, 40B93FA02A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */, @@ -8054,6 +8059,7 @@ 40C406F82B2053570068441C /* RouletteApi.swift in Sources */, 400EA7972A89F591004992CF /* LiveRoomEditViewModel.swift in Sources */, 40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */, + 7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */, 409143D32A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */, 40B93FA12A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */, 409868552DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */, diff --git a/SodaLive/Sources/ImagePicker/ImageCropGeometry.swift b/SodaLive/Sources/ImagePicker/ImageCropGeometry.swift new file mode 100644 index 00000000..1fe5250e --- /dev/null +++ b/SodaLive/Sources/ImagePicker/ImageCropGeometry.swift @@ -0,0 +1,15 @@ +import CoreGraphics + +enum ImageCropGeometry { + static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize { + guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else { + return .zero + } + + if imageSize.width / imageSize.height > aspectRatio { + return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height) + } + + return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio) + } +} diff --git a/SodaLive/Sources/ImagePicker/ImagePicker.swift b/SodaLive/Sources/ImagePicker/ImagePicker.swift index a8b1af23..407ef15e 100644 --- a/SodaLive/Sources/ImagePicker/ImagePicker.swift +++ b/SodaLive/Sources/ImagePicker/ImagePicker.swift @@ -274,27 +274,35 @@ struct ImageCropEditorView: View { private func cropSize(in canvas: CGSize) -> CGSize { switch aspectPolicy { case .square: - let side = max(120, min(canvas.width, canvas.height) * 0.72) - return CGSize(width: side, height: side) + let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas) + return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1) case .free: if freeCropSize == .zero { return defaultFreeCropSize(in: canvas) } - let minCropSize: CGFloat = 120 - let maxCropWidth = max(minCropSize, canvas.width - 24) - let maxCropHeight = max(minCropSize, canvas.height - 24) + let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas) + let maxCropWidth = fittedSize.width + let maxCropHeight = fittedSize.height + let minCropWidth = min(120, maxCropWidth) + let minCropHeight = min(120, maxCropHeight) return CGSize( - width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth), - height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight) + width: freeCropSize.width.clamped(min: minCropWidth, max: maxCropWidth), + height: freeCropSize.height.clamped(min: minCropHeight, max: maxCropHeight) ) } } private func defaultFreeCropSize(in canvas: CGSize) -> CGSize { - let width = max(120, min(canvas.width * 0.82, canvas.width - 24)) - let height = max(120, min(canvas.height * 0.58, canvas.height - 24)) - return CGSize(width: width, height: height) + let preferredSize = CGSize( + width: max(120, min(canvas.width * 0.82, canvas.width - 24)), + height: max(120, min(canvas.height * 0.58, canvas.height - 24)) + ) + let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas) + return ImageCropGeometry.maximumCropSize( + fitting: fittedSize, + aspectRatio: preferredSize.width / preferredSize.height + ) } private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint { @@ -318,14 +326,16 @@ struct ImageCropEditorView: View { lastFreeCropSize = currentCropSize } - let minCropSize: CGFloat = 120 - let maxCropWidth = max(minCropSize, canvasSize.width - 24) - let maxCropHeight = max(minCropSize, canvasSize.height - 24) + let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize) + let maxCropWidth = fittedSize.width + let maxCropHeight = fittedSize.height + let minCropWidth = min(120, maxCropWidth) + let minCropHeight = min(120, maxCropHeight) let width = (lastFreeCropSize.width + (translation.width * handle.xDirection)) - .clamped(min: minCropSize, max: maxCropWidth) + .clamped(min: minCropWidth, max: maxCropWidth) let height = (lastFreeCropSize.height + (translation.height * handle.yDirection)) - .clamped(min: minCropSize, max: maxCropHeight) + .clamped(min: minCropHeight, max: maxCropHeight) freeCropSize = CGSize(width: width, height: height) adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize) diff --git a/docs/20260803_이미지_크롭_범위_보정/plan-task.md b/docs/20260803_이미지_크롭_범위_보정/plan-task.md new file mode 100644 index 00000000..133a9b09 --- /dev/null +++ b/docs/20260803_이미지_크롭_범위_보정/plan-task.md @@ -0,0 +1,272 @@ +# 이미지 크롭 범위 보정 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 어떤 crop 비율에서도 fitted image의 가로 또는 세로 한 축 전체를 사용하는 최대 crop 영역을 제공하면서 서버 전송 이미지의 긴 변 최대 800px 정책을 유지한다. + +**Architecture:** UI와 독립된 `ImageCropGeometry.maximumCropSize`가 주어진 fitted image와 crop 비율로 최대 크기를 계산한다. `ImageCropEditorView`는 고정 crop의 크기, 자유 crop의 초기 크기와 resize 상한에 이 결과와 fitted image 경계를 사용하며 기존 crop 좌표 변환과 업로드 흐름은 유지한다. + +**Tech Stack:** Swift, SwiftUI, UIKit, CoreGraphics, `swiftc`, Xcode workspace + +--- + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-03 | +| 요구사항 기준 | `docs/20260803_이미지_크롭_범위_보정/prd.md` | +| API 기준 | 변경 없음 | +| 현재 Phase | Phase 1 | +| 현재 활성 Goal | 없음 | + +## 범위 + +### 포함 + +- fitted image 안의 최대 crop 크기 순수 계산 +- `.square` crop 초기 범위 보정 +- `.free` crop 초기 범위와 resize 상한 보정 +- geometry check, 앱 빌드, Simulator 수동 확인 +- 기존 최대 800px 결과 정책 회귀 확인 + +### 제외 + +- 외부 dependency 추가 +- crop 비율 선택 UI 및 새 `ImageCropAspectPolicy` case +- 호출 화면과 업로드 API 수정 +- 관련 없는 cropper 리팩터링 + +## 파일 구조 + +- Create: `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift` — crop 비율에 맞는 최대 fitted 크기 계산만 담당한다. +- Create: `work/tests/ImageCropGeometryCheck.swift` — 프레임워크 없이 실행하는 geometry 회귀 check다. +- Modify: `SodaLive/Sources/ImagePicker/ImagePicker.swift` — 기존 crop size와 자유 resize 경계를 순수 geometry 결과에 연결한다. +- Modify: `SodaLive.xcodeproj/project.pbxproj` — 새 production Swift 파일을 `SodaLive`, `SodaLive-dev` 두 target에 포함한다. +- Modify: `docs/20260803_이미지_크롭_범위_보정/plan-task.md` — 완료 체크와 실제 검증 결과를 누적한다. + +## Scenario Contract + +| Scenario | Binary pass condition | 자동 검증 | 실제 surface | +|---|---|---|---| +| 세로 이미지 | fitted `350x700`, 비율 `1` 결과가 `350x350`이다. | `ImageCropGeometryCheck/portraitUsesFullWidth` | Simulator에서 세로 이미지 선택 후 crop box 좌우가 이미지 경계와 일치한다. | +| 가로 이미지 | fitted `400x200`, 비율 `4/3` 결과가 약 `266.67x200`이다. | `ImageCropGeometryCheck/landscapeUsesFullHeight` | Simulator에서 가로 이미지 선택 후 현재 비율 crop box 상하가 이미지 경계와 일치한다. | +| 동일 비율 경계 | fitted `320x180`, 비율 `16/9` 결과가 `320x180`이다. | `ImageCropGeometryCheck/matchingRatioUsesWholeImage` | 계산 check 출력이 PASS다. | +| 잘못된 입력 | 0 이하 크기 또는 비율 결과가 `.zero`다. | `ImageCropGeometryCheck/invalidInputReturnsZero` | 계산 check 출력이 PASS다. | +| 인접 회귀 | crop 적용 결과가 기존 `resizedToMaxDimension(800)`을 거치고 앱이 빌드된다. | 정적 확인 + workspace build | Simulator에서 적용 후 기존 미리보기 화면으로 복귀한다. | + +**중단 조건:** 모든 scenario의 RED→GREEN 증거, Simulator 실제 화면 증거, 두 scheme build 성공, 변경 파일 진단, QA 자원 정리와 리뷰 승인이 확보되면 즉시 종료한다. + +## Phase 1 + +**Phase 결과:** 공통 cropper가 fitted image 기준 최대 crop 영역을 사용한다. + +**선행조건:** 승인된 `prd.md`. + +### Task 1.1 최대 crop geometry를 TDD로 구현 + +**Goal 실행 `P1-T1`:** crop 비율을 유지하면서 fitted image 안의 최대 크기를 반환하는 순수 함수를 제공한다. + +**Files:** + +- Create: `work/tests/ImageCropGeometryCheck.swift` +- Create: `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift` +- Modify: `SodaLive.xcodeproj/project.pbxproj` + +- [x] **RED:** `work/tests/ImageCropGeometryCheck.swift`를 다음 scenario로 작성한다. + +```swift +import CoreGraphics +import Foundation + +@main +struct ImageCropGeometryCheck { + static func main() { + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 350, height: 700), aspectRatio: 1), + equals: CGSize(width: 350, height: 350), + scenario: "portraitUsesFullWidth" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 400, height: 200), aspectRatio: 4.0 / 3.0), + equals: CGSize(width: 800.0 / 3.0, height: 200), + scenario: "landscapeUsesFullHeight" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 320, height: 180), aspectRatio: 16.0 / 9.0), + equals: CGSize(width: 320, height: 180), + scenario: "matchingRatioUsesWholeImage" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: .zero, aspectRatio: 1), + equals: .zero, + scenario: "invalidInputReturnsZero" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 100, height: 100), aspectRatio: 0), + equals: .zero, + scenario: "invalidRatioReturnsZero" + ) + print("ImageCropGeometryCheck PASS") + } + + private static func assertSize(_ actual: CGSize, equals expected: CGSize, scenario: String) { + let tolerance = 0.001 + precondition(abs(actual.width - expected.width) < tolerance, "\(scenario) width: \(actual.width)") + precondition(abs(actual.height - expected.height) < tolerance, "\(scenario) height: \(actual.height)") + } +} +``` + +- [x] **RED 확인:** production helper 없이 check를 컴파일해 `cannot find 'ImageCropGeometry' in scope` 실패를 확인한다. + +```bash +xcrun swiftc -parse-as-library work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck +``` + +- [x] **GREEN:** `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift`를 다음 최소 구현으로 추가한다. + +```swift +import CoreGraphics + +enum ImageCropGeometry { + static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize { + guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else { + return .zero + } + + if imageSize.width / imageSize.height > aspectRatio { + return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height) + } + + return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio) + } +} +``` + +- [x] **GREEN 확인:** helper와 check를 함께 컴파일·실행해 `ImageCropGeometryCheck PASS`를 확인한다. + +```bash +xcrun swiftc -parse-as-library SodaLive/Sources/ImagePicker/ImageCropGeometry.swift work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck +/tmp/ImageCropGeometryCheck +``` + +- [x] 새 helper를 `ImagePicker` group과 `SodaLive`, `SodaLive-dev` Sources phase에 각각 추가한다. +- [x] `/tmp/ImageCropGeometryCheck`를 삭제하고 삭제 결과를 기록한다. + +### Task 1.2 기존 cropper에 최대 범위 계산 연결 + +**Goal 실행 `P1-T2`:** 고정 crop과 자유 crop의 초기·최대 범위가 fitted image 경계를 사용한다. + +**Files:** + +- Modify: `SodaLive/Sources/ImagePicker/ImagePicker.swift:274-357` + +- [x] **RED:** Task 1.1의 check에 세로·가로·동일 비율·잘못된 입력 scenario가 모두 실패했던 기록이 있는지 확인한다. +- [x] **GREEN:** `.square`의 고정 `canvas * 0.72` 계산을 다음 fitted geometry 호출로 교체한다. + +```swift +let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas) +return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1) +``` + +- [x] **GREEN:** `defaultFreeCropSize(in:)`에서 기존 기본 비율을 유지하면서 fitted image 안의 최대 크기를 반환한다. + +```swift +let preferredSize = CGSize( + width: max(120, min(canvas.width * 0.82, canvas.width - 24)), + height: max(120, min(canvas.height * 0.58, canvas.height - 24)) +) +let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas) +return ImageCropGeometry.maximumCropSize( + fitting: fittedSize, + aspectRatio: preferredSize.width / preferredSize.height +) +``` + +- [x] **GREEN:** 자유 crop의 width/height 상한은 `fittedImageSize`를 사용하고, 각 최소값은 해당 상한과 `120` 중 작은 값으로 계산한다. + +```swift +let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize) +let maxCropWidth = fittedSize.width +let maxCropHeight = fittedSize.height +let minCropWidth = min(120, maxCropWidth) +let minCropHeight = min(120, maxCropHeight) +``` + +- [x] **REFACTOR:** `cropImage()`의 좌표 변환과 `resizedToMaxDimension(800)`, public initializer, 5개 호출부는 수정하지 않는다. +- [x] **GREEN 확인:** geometry check를 다시 실행해 PASS를 확인한다. + +### Task 1.3 검증 및 기록 + +**Goal 실행 `P1-GATE`:** 계산, 빌드, 실제 crop 화면과 기존 결과 크기 정책을 최종 판정한다. + +- [x] `lsp_diagnostics`를 `ImageCropGeometry.swift`, `ImagePicker.swift`에 실행해 신규 오류 0건을 확인한다. +- [x] 다음 build를 실행해 두 scheme 모두 `BUILD SUCCEEDED`를 확인한다. + +```bash +xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build +xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build +``` + +- [x] `rg -n "resizedToMaxDimension\(800\)" SodaLive/Sources/ImagePicker/ImagePicker.swift`가 기존 반환 경로 1건을 출력하는지 확인한다. +- [x] Simulator에 앱을 설치·실행하고 현재 자동화 가능한 실제 화면 screenshot을 저장한다. +- [x] 자동 도구로 cropper 진입까지 조작할 수 없어 실제 crop handle 조작은 독립 reviewer 판정과 함께 수동 QA 한계로 기록한다. +- [x] QA용 임시 실행 파일, Simulator 상태 등 생성 자원을 정리하고 결과를 기록한다. +- [x] 변경 diff를 고강도 reviewer에게 제출하고 수용 기준을 위반하는 blocker가 없음을 확인한다. +- [x] 실제 명령, 결과, screenshot 경로와 남은 항목을 아래 Progress에 누적한다. + +## 변경 금지 항목 + +- `resizedToMaxDimension(800)`과 JPEG/multipart 업로드 경로를 변경하지 않는다. +- 새 crop 비율 UI, external dependency 또는 공통 abstraction을 추가하지 않는다. +- 기존 자유 crop 모서리 gesture와 offset/scale 동작을 범위 밖에서 수정하지 않는다. +- 테스트를 삭제·완화하거나 타입 오류를 우회하지 않는다. +- 사용자 요청 없이 git commit을 만들지 않는다. + +## Progress + +### `P1` 계획 작성 — 2026-08-03 + +- 상태: 계획 작성 완료 +- 무엇을: 승인된 최대 crop 범위와 기존 최대 800px 전송 정책을 PRD 및 TDD 계획으로 확정했다. +- 왜: canvas 고정 크기로 인해 원본의 가로 또는 세로 전체를 사용하지 못하는 문제를 최소 수정으로 해결하기 위해서다. +- 어떻게: 현재 `ImagePicker.swift`, 5개 호출부, 기존 crop 문서, dependency와 테스트 구성을 확인했다. +- 남은 항목: 사용자 명세 검토, `P1-T1`, `P1-T2`, `P1-GATE`. +- 다음 행동: 명세 승인 후 RED geometry check 작성. + +### `P1-T1`/`P1-T2` 구현 — 2026-08-03 + +- 상태: 완료 +- 무엇을: `ImageCropGeometry.maximumCropSize`를 추가하고 `ImageCropEditorView`의 `.square`와 `.free` crop 크기 계산을 fitted image 기준 최대 영역으로 연결했다. +- 왜: crop 비율과 관계없이 원본 표시 영역 안에서 가능한 최대 crop rect를 사용하기 위해서다. +- 어떻게: + - `xcrun swiftc -parse-as-library work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck` — RED 확인, `cannot find 'ImageCropGeometry' in scope` 실패. + - `xcrun swiftc -parse-as-library SodaLive/Sources/ImagePicker/ImageCropGeometry.swift work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck && /tmp/ImageCropGeometryCheck` — GREEN 확인, `ImageCropGeometryCheck PASS`. + - `lsp_diagnostics SodaLive/Sources/ImagePicker/ImageCropGeometry.swift` — 오류 0건. + - `lsp_diagnostics SodaLive/Sources/ImagePicker/ImagePicker.swift` — SourceKit 환경에서 `No such module 'UIKit'` 발생. 이 저장소의 SourceKit 환경 한계로 보고 Xcode build로 대체 검증. +- 남은 항목: 없음. +- 다음 행동: Gate 검증과 자원 정리. + +### `P1-GATE` 검증 — 2026-08-03 + +- 상태: 완료 +- 무엇을: geometry check, 두 app scheme build, Simulator build/launch, 결과 크기 정책 유지, 독립 리뷰를 확인했다. +- 왜: 실제 앱 타깃과 기존 서버 전송 정책을 깨지 않았는지 확인하기 위해서다. +- 어떻게: + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` — 성공, 최종 `** BUILD SUCCEEDED **`. + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 15,OS=17.5" build` — 성공, `** BUILD SUCCEEDED **`. + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` — 기존 프로젝트 상태와 동일하게 `Scheme SodaLive is not currently configured for the test action.`로 테스트 액션 미구성 확인. + - `rg -n "resizedToMaxDimension\(800\)" SodaLive/Sources/ImagePicker/ImagePicker.swift` — 1건, 기존 반환 정책 유지 확인. + - Simulator `C012DC2D-6F85-4C9F-AF22-A10621385D53`에 `SodaLive-dev` 설치 및 `kr.co.vividnext.sodalive.debug2` 실행 — 정상 홈 화면 screenshot 저장: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-crop-qa/app-launch.png`. + - 독립 Oracle review — `VERDICT: PASS`, blocker 없음. 실제 crop 화면 handle 조작 증거는 자동화 경로 미확보로 non-blocking manual QA gap으로 판정. + - `/tmp/ImageCropGeometryCheck` 삭제, Simulator 앱 uninstall 및 shutdown 완료. +- 남은 항목: 실제 기기 또는 수동 Simulator에서 이미지 선택 후 cropper handle 조작 확인 권장. +- 다음 행동: 최종 보고. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-03 | `DEC-001` | 확정 | 외부 cropper를 추가하지 않고 기존 geometry를 보정한다. | 문제 범위가 공통 crop 계산에 한정된다. | `P1-T1`, `P1-T2` | +| 2026-08-03 | `DEC-002` | 확정 | 모든 crop 비율에 fitted image 최대 영역 규칙을 적용한다. | 1:1은 예시라는 사용자 확인 | `P1-T1`, `P1-T2` | +| 2026-08-03 | `DEC-003` | 확정 | 긴 변 최대 800px 결과 정책을 유지한다. | 서버 전송 크기 유지 요구 | `P1-GATE` | diff --git a/docs/20260803_이미지_크롭_범위_보정/prd.md b/docs/20260803_이미지_크롭_범위_보정/prd.md new file mode 100644 index 00000000..5c4d62b4 --- /dev/null +++ b/docs/20260803_이미지_크롭_범위_보정/prd.md @@ -0,0 +1,84 @@ +# 이미지 크롭 범위 보정 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-08-03 | +| 최종 수정일 | 2026-08-03 | +| 대상 제품 | SodaLive 이미지 선택 후 크롭 편집기 | +| 관련 구현 계획 | `docs/20260803_이미지_크롭_범위_보정/plan-task.md` | +| 관련 과거 기록 | `docs/plan-task/20260317_이미지등록크롭재구현.md`, `docs/plan-task/20260317_이미지선택크롭지연및미적용수정.md` | + +## 1. Overview + +이미지를 선택한 뒤 표시되는 공통 `ImageCropEditorView`가 crop 비율에 맞는 원본 최대 범위를 사용하도록 보정한다. 외부 cropper 의존성은 추가하지 않고 현재 편집 흐름과 서버 전송 규격을 유지한다. + +## 2. Problem Statement + +- 고정 1:1 crop 영역은 canvas 최소 변의 72%로 계산되어 원본 표시 영역보다 불필요하게 작을 수 있다. +- 자유 crop 영역의 초기 크기와 최대 크기도 canvas 기준이라 원본 표시 영역과 일치하지 않는다. +- 따라서 crop 비율에 따라 원본의 가로 또는 세로 한 축 전체를 포함할 수 있어도 더 작은 영역만 잘린다. + +문제 해결 여부는 세로·가로 이미지에서 현재 crop 비율을 유지하는 최대 사각형이 원본 표시 영역의 가로 또는 세로 한 축과 일치하는지로 판정한다. + +## 3. Goals + +- 어떤 crop 비율에서도 원본 표시 영역 안에 들어가는 최대 crop 사각형을 계산한다. +- 세로 이미지는 가능한 경우 표시된 이미지의 전체 가로를, 가로 이미지는 전체 세로를 crop 영역에 포함한다. +- 자유 crop은 최대 범위를 fitted image 기준으로 제한하면서 기존 모서리 resize 동작을 유지한다. +- 최종 crop 결과의 긴 변 최대 800px 정책과 기존 서버 multipart 경로를 유지한다. + +## 4. Non-Goals + +- 외부 cropper 라이브러리 추가 또는 기존 편집기 교체 +- 새로운 crop 비율 선택 UI 추가 +- 이미지 선택 화면, 업로드 API, multipart 필드 변경 +- 회전, 반전, 원근 보정 기능 추가 + +## 5. 핵심 사용자 흐름 + +1. 사용자가 기존 화면에서 이미지를 선택한다. +2. 앱이 이미지를 정규화하고 `ImageCropEditorView`를 표시한다. +3. 편집기는 현재 crop 비율에 맞는 최대 영역을 fitted image 안에서 계산한다. +4. 사용자가 이동·확대 또는 자유 crop resize 후 적용한다. +5. 앱은 기존처럼 crop 결과를 긴 변 최대 800px로 축소해 각 ViewModel의 기존 업로드 경로로 전달한다. + +## 6. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | +|---|---|---|---| +| `CROP-001` | 확정 | crop 비율을 유지하는 최대 크기를 fitted image 안에서 계산한다. | 반환 rect의 가로 또는 세로가 fitted image의 같은 축과 일치하고 두 축 모두 경계를 넘지 않는다. | +| `CROP-002` | 확정 | 고정 1:1 crop에 최대 범위 계산을 적용한다. | 세로 이미지는 전체 가로, 가로 이미지는 전체 세로를 초기 crop 영역으로 사용한다. | +| `CROP-003` | 확정 | 자유 crop의 초기 영역과 resize 상한을 fitted image 기준으로 계산한다. | 초기 영역은 현재 기본 비율의 최대 크기이며 모서리 resize 결과가 fitted image 크기를 넘지 않는다. | +| `CROP-004` | 확정 | 최종 전송 이미지 크기 정책을 유지한다. | `cropImage()`가 계속 `resizedToMaxDimension(800)` 결과를 반환한다. | +| `CROP-005` | 확정 | 기존 호출부 계약을 유지한다. | `ImageCropAspectPolicy`, `onCancel`, `onComplete(UIImage)`와 5개 호출부 수정이 없다. | + +## 7. 기술 제약 + +- iOS 16.6 이상, SwiftUI와 UIKit을 유지한다. +- geometry 계산은 UI와 분리된 순수 함수로 두어 독립 실행 검증이 가능해야 한다. +- 테스트 번들 타깃이 없는 현재 저장소 구조에서는 `swiftc`로 순수 geometry check를 실행한다. +- `Pods/**`, `generated/**`, 업로드 API는 수정하지 않는다. + +## 8. 성공 기준 + +- [ ] 세로 fitted image `350x700`, 비율 `1:1`에서 crop 크기가 `350x350`이다. +- [ ] 가로 fitted image `400x200`, 비율 `4:3`에서 crop 크기가 약 `266.67x200`이다. +- [ ] fitted image와 crop 비율이 같으면 전체 fitted image 크기를 반환한다. +- [ ] 0 이하 크기 또는 비율은 `.zero`를 반환한다. +- [ ] 자유 crop resize 상한이 fitted image를 넘지 않는다. +- [ ] 앱 빌드가 성공하고 최종 crop 결과의 긴 변 최대 800px 정책이 유지된다. + +## 9. Open Questions + +해당 없음. + +## 10. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 | +|---|---|---|---|---|---| +| 2026-08-03 | `DEC-001` | 확정 | 외부 라이브러리 대신 기존 cropper geometry를 보정한다. | 현재 문제는 공통 `cropSize`와 fitted image 경계 계산에서 발생하며 한 컴포넌트에서 해결 가능하다. | `CROP-001~005` | +| 2026-08-03 | `DEC-002` | 확정 | 1:1은 예시이며 모든 crop 비율에 최대 fitted 영역 규칙을 적용한다. | 사용자 확인 | `CROP-001~003` | +| 2026-08-03 | `DEC-003` | 확정 | 최종 이미지 긴 변 최대 800px 정책을 유지한다. | 서버 전송 크기를 기존과 동일하게 유지한다는 사용자 확인 | `CROP-004` | diff --git a/work/tests/ImageCropGeometryCheck.swift b/work/tests/ImageCropGeometryCheck.swift new file mode 100644 index 00000000..771d2461 --- /dev/null +++ b/work/tests/ImageCropGeometryCheck.swift @@ -0,0 +1,40 @@ +import CoreGraphics +import Foundation + +@main +struct ImageCropGeometryCheck { + static func main() { + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 350, height: 700), aspectRatio: 1), + equals: CGSize(width: 350, height: 350), + scenario: "portraitUsesFullWidth" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 400, height: 200), aspectRatio: 4.0 / 3.0), + equals: CGSize(width: 800.0 / 3.0, height: 200), + scenario: "landscapeUsesFullHeight" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 320, height: 180), aspectRatio: 16.0 / 9.0), + equals: CGSize(width: 320, height: 180), + scenario: "matchingRatioUsesWholeImage" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: .zero, aspectRatio: 1), + equals: .zero, + scenario: "invalidInputReturnsZero" + ) + assertSize( + ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 100, height: 100), aspectRatio: 0), + equals: .zero, + scenario: "invalidRatioReturnsZero" + ) + print("ImageCropGeometryCheck PASS") + } + + private static func assertSize(_ actual: CGSize, equals expected: CGSize, scenario: String) { + let tolerance = 0.001 + precondition(abs(actual.width - expected.width) < tolerance, "\(scenario) width: \(actual.width)") + precondition(abs(actual.height - expected.height) < tolerance, "\(scenario) height: \(actual.height)") + } +}