fix(imagepicker): 크롭 범위를 보정한다

This commit is contained in:
Yu Sung
2026-08-03 21:13:45 +09:00
parent 9320657bac
commit 2db4ef1c35
6 changed files with 442 additions and 15 deletions

View File

@@ -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` |