fix(imagepicker): 크롭 범위를 보정한다
This commit is contained in:
272
docs/20260803_이미지_크롭_범위_보정/plan-task.md
Normal file
272
docs/20260803_이미지_크롭_범위_보정/plan-task.md
Normal 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` |
|
||||
Reference in New Issue
Block a user