react-native-photo-video-editor 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
  3. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  4. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  5. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  6. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  7. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  8. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
  9. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  10. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  11. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
  12. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
  13. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  14. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  15. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  16. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  17. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  18. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  19. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  20. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  21. package/docs/editor-ui-validation.md +22 -0
  22. package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
  23. package/ios/Editor/UI/CropPanel.swift +97 -0
  24. package/ios/Editor/UI/EditorPanel.swift +334 -0
  25. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  26. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  27. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  28. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  29. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  30. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  31. package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
  32. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
  33. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  34. package/ios/Photo/UI/CropOverlayView.swift +96 -169
  35. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  36. package/ios/Photo/UI/LayerOverlayView.swift +27 -10
  37. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  38. package/ios/Video/Export/VideoExporter.swift +10 -1
  39. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  40. package/ios/Video/Render/VideoTransformState.swift +1 -0
  41. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  import Foundation
2
2
 
3
- /// A small set of original, neutrally named preset filters (no third-party
4
- /// looks or names copied). Each recipe is applied by the renderer as:
5
- /// 1) set saturation, 2) scale RGB, 3) add the per-channel offset —
6
- /// mirroring the matrices in `PhotoFilterPresets.kt`.
3
+ /// Original, neutrally named preset filters (no third-party looks or names copied).
4
+ ///
5
+ /// Every preset is expressed as the same four knobs the renderer already supports, so a preset can
6
+ /// never be a button that does nothing: 1) set saturation, 2) scale RGB, 3) add the per-channel
7
+ /// offset. `PhotoFilterPresets.kt` mirrors this table field-for-field — add presets to both or
8
+ /// neither, or the two platforms will render different looks for the same id.
7
9
  struct PhotoFilterRecipe {
8
10
  let scale: Double
9
11
  let redOffset: Double
@@ -13,33 +15,61 @@ struct PhotoFilterRecipe {
13
15
  }
14
16
 
15
17
  enum PhotoFilterPresets {
16
- static let presetIDs = ["mono", "noir", "fade", "chrome", "warmth"]
18
+ /// Display order in the carousel. "Original" (no preset) is prepended by the UI, not listed here.
19
+ static let presetIDs = [
20
+ "vivid", "vividWarm", "vividCool",
21
+ "warmth", "cool",
22
+ "natural", "soft", "fade",
23
+ "mono", "noir", "silvertone",
24
+ "vintage", "sepia", "retro",
25
+ "dramatic", "dramaticWarm", "dramaticCool",
26
+ ]
27
+
28
+ private static let recipes: [String: PhotoFilterRecipe] = [
29
+ "vivid": PhotoFilterRecipe(scale: 1.05, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.45),
30
+ "vividWarm": PhotoFilterRecipe(scale: 1.05, redOffset: 12, greenOffset: 4, blueOffset: -10, saturation: 1.45),
31
+ "vividCool": PhotoFilterRecipe(scale: 1.05, redOffset: -8, greenOffset: 0, blueOffset: 14, saturation: 1.45),
32
+ "warmth": PhotoFilterRecipe(scale: 1, redOffset: 18, greenOffset: 6, blueOffset: -12, saturation: 1.05),
33
+ "cool": PhotoFilterRecipe(scale: 1, redOffset: -12, greenOffset: 0, blueOffset: 18, saturation: 1.05),
34
+ "natural": PhotoFilterRecipe(scale: 1.02, redOffset: 2, greenOffset: 2, blueOffset: 0, saturation: 1.15),
35
+ "soft": PhotoFilterRecipe(scale: 0.96, redOffset: 14, greenOffset: 12, blueOffset: 12, saturation: 0.9),
36
+ "fade": PhotoFilterRecipe(scale: 0.9, redOffset: 25, greenOffset: 25, blueOffset: 25, saturation: 1),
37
+ "mono": PhotoFilterRecipe(scale: 1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 0),
38
+ "noir": PhotoFilterRecipe(scale: 1.3, redOffset: -40, greenOffset: -40, blueOffset: -40, saturation: 0),
39
+ "silvertone": PhotoFilterRecipe(scale: 1.1, redOffset: -6, greenOffset: -2, blueOffset: 10, saturation: 0),
40
+ "vintage": PhotoFilterRecipe(scale: 0.95, redOffset: 24, greenOffset: 12, blueOffset: -6, saturation: 0.6),
41
+ "sepia": PhotoFilterRecipe(scale: 1, redOffset: 38, greenOffset: 20, blueOffset: -14, saturation: 0),
42
+ "retro": PhotoFilterRecipe(scale: 0.92, redOffset: 20, greenOffset: 6, blueOffset: 14, saturation: 0.75),
43
+ "dramatic": PhotoFilterRecipe(scale: 1.25, redOffset: -28, greenOffset: -28, blueOffset: -28, saturation: 1.2),
44
+ "dramaticWarm": PhotoFilterRecipe(scale: 1.25, redOffset: -14, greenOffset: -26, blueOffset: -38, saturation: 1.2),
45
+ "dramaticCool": PhotoFilterRecipe(scale: 1.25, redOffset: -38, greenOffset: -28, blueOffset: -12, saturation: 1.2),
46
+ // Retained so photos saved with an earlier preset id still render; not offered in the carousel.
47
+ "chrome": PhotoFilterRecipe(scale: 1.1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.4),
48
+ ]
17
49
 
18
50
  static func label(for id: String) -> String {
19
51
  switch id {
52
+ case "vivid": return "Vivid"
53
+ case "vividWarm": return "Vivid Warm"
54
+ case "vividCool": return "Vivid Cool"
55
+ case "warmth": return "Warm"
56
+ case "cool": return "Cool"
57
+ case "natural": return "Natural"
58
+ case "soft": return "Soft"
59
+ case "fade": return "Fade"
20
60
  case "mono": return "Mono"
21
61
  case "noir": return "Noir"
22
- case "fade": return "Fade"
62
+ case "silvertone": return "Silvertone"
63
+ case "vintage": return "Vintage"
64
+ case "sepia": return "Sepia"
65
+ case "retro": return "Retro"
66
+ case "dramatic": return "Dramatic"
67
+ case "dramaticWarm": return "Dramatic Warm"
68
+ case "dramaticCool": return "Dramatic Cool"
23
69
  case "chrome": return "Chrome"
24
- case "warmth": return "Warmth"
25
70
  default: return id
26
71
  }
27
72
  }
28
73
 
29
- static func recipe(for id: String) -> PhotoFilterRecipe? {
30
- switch id {
31
- case "mono":
32
- return PhotoFilterRecipe(scale: 1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 0)
33
- case "noir":
34
- return PhotoFilterRecipe(scale: 1.3, redOffset: -40, greenOffset: -40, blueOffset: -40, saturation: 0)
35
- case "fade":
36
- return PhotoFilterRecipe(scale: 0.9, redOffset: 25, greenOffset: 25, blueOffset: 25, saturation: 1)
37
- case "chrome":
38
- return PhotoFilterRecipe(scale: 1.1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.4)
39
- case "warmth":
40
- return PhotoFilterRecipe(scale: 1, redOffset: 18, greenOffset: 6, blueOffset: -12, saturation: 1)
41
- default:
42
- return nil
43
- }
44
- }
74
+ static func recipe(for id: String) -> PhotoFilterRecipe? { recipes[id] }
45
75
  }
@@ -11,7 +11,7 @@ enum LayerType {
11
11
  ///
12
12
  /// `x`/`y` are the layer's center, normalized 0..1 against the image.
13
13
  struct PhotoLayer {
14
- let id: String
14
+ var id: String
15
15
  var type: LayerType
16
16
  var x: CGFloat = 0.5
17
17
  var y: CGFloat = 0.5
@@ -44,6 +44,14 @@ struct PhotoLayer {
44
44
  self.type = type
45
45
  }
46
46
 
47
+ func duplicated() -> PhotoLayer {
48
+ var copy = self
49
+ copy.id = UUID().uuidString
50
+ copy.x = min(max(x + 0.04, 0), 1)
51
+ copy.y = min(max(y + 0.04, 0), 1)
52
+ return copy
53
+ }
54
+
47
55
  /// True if this layer should be visible at `positionMs` against a video of `durationMs`. Always true for photo layers.
48
56
  func effectiveEndMs(durationMs: Int64) -> Int64 {
49
57
  (endMs > 0 && endMs <= durationMs) ? endMs : durationMs
@@ -54,8 +54,9 @@ enum PhotoLayerRenderer {
54
54
 
55
55
  private static func drawSticker(_ layer: PhotoLayer, shortSide: CGFloat, resolver: (String) -> UIImage?) {
56
56
  let size = shortSide * 0.18
57
- let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
58
57
  if let uri = layer.stickerUri, let image = resolver(uri) {
58
+ let aspect = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? (image.size.width / max(image.size.height, 1))
59
+ let rect = CGRect(x: -size / 2, y: -size / (2 * aspect), width: size, height: size / aspect)
59
60
  image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
60
61
  return
61
62
  }
@@ -12,6 +12,11 @@ struct PhotoTransformState: Equatable {
12
12
  var rotationDegrees: Int = 0
13
13
  var straightenDegrees: CGFloat = 0
14
14
  var aspectRatio: CGFloat?
15
+ var aspectPreset = "Free"
16
+ // Zoom/pan are viewport metadata; normalized crop coordinates already encode them for export.
17
+ var zoom: CGFloat = 1
18
+ var panX: CGFloat = 0
19
+ var panY: CGFloat = 0
15
20
 
16
21
  var isIdentity: Bool {
17
22
  cropLeft == 0 && cropTop == 0 && cropRight == 1 && cropBottom == 1 &&
@@ -1,198 +1,125 @@
1
1
  import UIKit
2
2
 
3
- /// Draws a draggable/resizable crop rectangle over `imageBounds` (the
4
- /// on-screen rect of the preview image, in this view's own coordinate space)
5
- /// and reports the crop edges back normalized to that rect via `onCropChanged`.
3
+ /// Handle gestures resize the box; interior gestures pan/pinch the media, keeping the box fixed.
6
4
  final class CropOverlayView: UIView {
7
- var onCropChanged: ((_ left: CGFloat, _ top: CGFloat, _ right: CGFloat, _ bottom: CGFloat) -> Void)?
8
-
9
- private var imageBounds: CGRect = .zero
10
- private var cropRect: CGRect = .zero
5
+ var onCropChanged: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)?
6
+ var onMediaBoundsChanged: ((CGRect) -> Void)?
7
+ var onViewportChanged: ((CGFloat, CGFloat, CGFloat) -> Void)?
8
+ private var imageBounds = CGRect.zero, fittedBounds = CGRect.zero, cropRect = CGRect.zero
11
9
  private var aspectRatio: CGFloat?
12
-
13
- private let dimLayer = CAShapeLayer()
14
- private let borderLayer = CAShapeLayer()
15
- private let handlesLayer = CAShapeLayer()
16
- private let handleRadius: CGFloat = 32
17
- private let minSize: CGFloat = 60
18
-
19
- private enum Handle { case move, top, right, bottom, left, topLeft, topRight, bottomLeft, bottomRight }
20
- private var activeHandle: Handle?
21
- private var lastPoint: CGPoint = .zero
10
+ private var activeHandle: CropGeometry.Handle?
11
+ private var primary: UITouch?, secondary: UITouch?
12
+ private var lastPoint = CGPoint.zero
13
+ private var lastDistance: CGFloat = 0
22
14
 
23
15
  override init(frame: CGRect) {
24
- super.init(frame: frame)
25
- backgroundColor = .clear
26
- dimLayer.fillRule = .evenOdd
27
- dimLayer.fillColor = UIColor.black.withAlphaComponent(0.6).cgColor
28
- layer.addSublayer(dimLayer)
29
- borderLayer.strokeColor = UIColor.white.cgColor
30
- borderLayer.fillColor = UIColor.clear.cgColor
31
- borderLayer.lineWidth = 2
32
- layer.addSublayer(borderLayer)
33
- handlesLayer.fillColor = UIColor.white.cgColor
34
- layer.addSublayer(handlesLayer)
16
+ super.init(frame: frame); backgroundColor = .clear; isOpaque = false; isMultipleTouchEnabled = true
35
17
  }
36
-
37
18
  required init?(coder: NSCoder) { nil }
38
19
 
39
- /// Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when `resetCrop` is true.
20
+ func restore(bounds: CGRect, state: PhotoTransformState) {
21
+ guard bounds.width > 0, bounds.height > 0 else { return }
22
+ fittedBounds = bounds
23
+ let zoom = max(1, min(8, state.zoom))
24
+ let cx = bounds.midX + state.panX*bounds.width, cy = bounds.midY + state.panY*bounds.height
25
+ imageBounds = CGRect(x: cx-bounds.width*zoom/2, y: cy-bounds.height*zoom/2, width: bounds.width*zoom, height: bounds.height*zoom)
26
+ aspectRatio = state.aspectRatio
27
+ setCrop(left: state.cropLeft, top: state.cropTop, right: state.cropRight, bottom: state.cropBottom)
28
+ onMediaBoundsChanged?(imageBounds)
29
+ }
40
30
  func setImageBounds(_ bounds: CGRect, resetCrop: Bool) {
41
- imageBounds = bounds
42
- if resetCrop || cropRect.width <= 0 || cropRect.height <= 0 {
43
- cropRect = bounds
44
- reportCrop()
45
- }
46
- setNeedsLayout()
31
+ let old = normalized()
32
+ imageBounds = bounds; fittedBounds = bounds
33
+ if resetCrop || cropRect.isEmpty { cropRect = bounds }
34
+ else { setCrop(left: old.minX, top: old.minY, right: old.maxX, bottom: old.maxY) }
35
+ setNeedsDisplay()
47
36
  }
48
-
49
- /// Restores a normalized crop rectangle, used when reopening or cancelling crop mode.
50
37
  func setCrop(left: CGFloat, top: CGFloat, right: CGFloat, bottom: CGFloat) {
51
- guard imageBounds.width > 0, imageBounds.height > 0 else { return }
52
- cropRect = CGRect(
53
- x: imageBounds.minX + left * imageBounds.width,
54
- y: imageBounds.minY + top * imageBounds.height,
55
- width: (right - left) * imageBounds.width,
56
- height: (bottom - top) * imageBounds.height
57
- )
58
- clampToImageBounds(); setNeedsLayout()
38
+ cropRect = CGRect(x: imageBounds.minX+left*imageBounds.width, y: imageBounds.minY+top*imageBounds.height,
39
+ width: (right-left)*imageBounds.width, height: (bottom-top)*imageBounds.height)
40
+ setNeedsDisplay()
59
41
  }
60
-
61
42
  func setAspectRatio(_ ratio: CGFloat?) {
62
43
  aspectRatio = ratio
63
- if let ratio, imageBounds.width > 0, imageBounds.height > 0 {
64
- let center = CGPoint(x: cropRect.midX, y: cropRect.midY)
65
- var width = cropRect.width
66
- var height = width / ratio
67
- if height > imageBounds.height {
68
- height = cropRect.height
69
- width = height * ratio
70
- }
71
- cropRect = CGRect(x: center.x - width / 2, y: center.y - height / 2, width: width, height: height)
72
- clampToImageBounds()
73
- setNeedsLayout()
74
- }
75
- reportCrop()
44
+ cropRect = CropGeometry.fit(imageBounds.intersection(bounds), ratio: ratio)
45
+ reportCrop(); setNeedsDisplay()
76
46
  }
77
47
 
78
- override func layoutSublayers(of layer: CALayer) {
79
- super.layoutSublayers(of: layer)
80
- guard imageBounds.width > 0, imageBounds.height > 0 else { return }
81
- let outerPath = UIBezierPath(rect: imageBounds)
82
- outerPath.append(UIBezierPath(rect: cropRect).reversing())
83
- dimLayer.path = outerPath.cgPath
84
-
85
- let borderPath = UIBezierPath(rect: cropRect)
86
- let thirdWidth = cropRect.width / 3
87
- let thirdHeight = cropRect.height / 3
88
- for index in 1...2 {
89
- let x = cropRect.minX + thirdWidth * CGFloat(index)
90
- borderPath.move(to: CGPoint(x: x, y: cropRect.minY))
91
- borderPath.addLine(to: CGPoint(x: x, y: cropRect.maxY))
92
- let y = cropRect.minY + thirdHeight * CGFloat(index)
93
- borderPath.move(to: CGPoint(x: cropRect.minX, y: y))
94
- borderPath.addLine(to: CGPoint(x: cropRect.maxX, y: y))
48
+ override func draw(_ rect: CGRect) {
49
+ guard !cropRect.isEmpty, let context = UIGraphicsGetCurrentContext() else { return }
50
+ let shade = UIBezierPath(rect: bounds); shade.append(UIBezierPath(rect: cropRect)); shade.usesEvenOddFillRule = true
51
+ UIColor.black.withAlphaComponent(0.65).setFill(); shade.fill()
52
+ context.setStrokeColor(UIColor.white.cgColor); context.setLineWidth(1); context.stroke(cropRect)
53
+ context.setStrokeColor(UIColor.white.withAlphaComponent(0.33).cgColor); context.setLineWidth(0.5)
54
+ for i in 1...2 {
55
+ let x = cropRect.minX+cropRect.width*CGFloat(i)/3, y = cropRect.minY+cropRect.height*CGFloat(i)/3
56
+ context.move(to: CGPoint(x:x,y:cropRect.minY)); context.addLine(to:CGPoint(x:x,y:cropRect.maxY))
57
+ context.move(to: CGPoint(x:cropRect.minX,y:y)); context.addLine(to:CGPoint(x:cropRect.maxX,y:y))
95
58
  }
96
- borderLayer.path = borderPath.cgPath
97
-
98
- let handlesPath = UIBezierPath()
99
- let points = [
100
- CGPoint(x: cropRect.minX, y: cropRect.minY), CGPoint(x: cropRect.midX, y: cropRect.minY),
101
- CGPoint(x: cropRect.maxX, y: cropRect.minY), CGPoint(x: cropRect.maxX, y: cropRect.midY),
102
- CGPoint(x: cropRect.maxX, y: cropRect.maxY), CGPoint(x: cropRect.midX, y: cropRect.maxY),
103
- CGPoint(x: cropRect.minX, y: cropRect.maxY), CGPoint(x: cropRect.minX, y: cropRect.midY),
104
- ]
105
- points.forEach { point in handlesPath.append(UIBezierPath(ovalIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8))) }
106
- handlesLayer.path = handlesPath.cgPath
59
+ context.strokePath(); context.setStrokeColor(UIColor.white.cgColor); context.setLineWidth(3); context.setLineCap(.round)
60
+ for x in [cropRect.minX,cropRect.maxX] {
61
+ for y in [cropRect.minY,cropRect.maxY] {
62
+ context.move(to: CGPoint(x:x+(x == cropRect.minX ? 14 : -14),y:y)); context.addLine(to:CGPoint(x:x,y:y))
63
+ context.addLine(to: CGPoint(x:x,y:y+(y == cropRect.minY ? 14 : -14)))
64
+ }
65
+ }
66
+ for y in [cropRect.minY,cropRect.maxY] { context.move(to:CGPoint(x:cropRect.midX-8,y:y)); context.addLine(to:CGPoint(x:cropRect.midX+8,y:y)) }
67
+ for x in [cropRect.minX,cropRect.maxX] { context.move(to:CGPoint(x:x,y:cropRect.midY-8)); context.addLine(to:CGPoint(x:x,y:cropRect.midY+8)) }
68
+ context.strokePath()
107
69
  }
108
70
 
109
71
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
110
- guard let point = touches.first?.location(in: self) else { return }
111
- activeHandle = hitTest(point)
112
- lastPoint = point
72
+ for touch in touches { if primary == nil { primary = touch } else if secondary == nil { secondary = touch } }
73
+ guard let primary else { return }
74
+ if secondary != nil { activeHandle = nil } else { activeHandle = hitHandle(primary.location(in:self)) }
75
+ let metrics = touchMetrics(); lastPoint = metrics.0; lastDistance = metrics.1
113
76
  }
114
-
115
77
  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
116
- guard let handle = activeHandle, let point = touches.first?.location(in: self) else { return }
117
- let dx = point.x - lastPoint.x
118
- let dy = point.y - lastPoint.y
119
- lastPoint = point
120
- applyDrag(handle, dx: dx, dy: dy)
121
- setNeedsLayout()
122
- reportCrop()
123
- }
124
-
125
- override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
126
- override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
127
-
128
- private func hitTest(_ point: CGPoint) -> Handle? {
129
- func near(_ target: CGPoint) -> Bool {
130
- abs(point.x - target.x) < handleRadius && abs(point.y - target.y) < handleRadius
78
+ guard primary != nil, imageBounds.width > 0 else { return }
79
+ let (point,distance) = touchMetrics(), dx = point.x-lastPoint.x, dy = point.y-lastPoint.y
80
+ if let activeHandle {
81
+ cropRect = CropGeometry.resize(cropRect, bounds: imageBounds.intersection(bounds), handle: activeHandle, dx: dx, dy: dy, ratio: aspectRatio, minimum: 48)
82
+ } else {
83
+ moveMedia(dx:dx,dy:dy,factor:lastDistance > 0 && distance > 0 ? distance/lastDistance : 1,focus:point)
131
84
  }
132
- if near(CGPoint(x: cropRect.minX, y: cropRect.minY)) { return .topLeft }
133
- if near(CGPoint(x: cropRect.maxX, y: cropRect.minY)) { return .topRight }
134
- if near(CGPoint(x: cropRect.minX, y: cropRect.maxY)) { return .bottomLeft }
135
- if near(CGPoint(x: cropRect.maxX, y: cropRect.maxY)) { return .bottomRight }
136
- if near(CGPoint(x: cropRect.midX, y: cropRect.minY)) { return .top }
137
- if near(CGPoint(x: cropRect.maxX, y: cropRect.midY)) { return .right }
138
- if near(CGPoint(x: cropRect.midX, y: cropRect.maxY)) { return .bottom }
139
- if near(CGPoint(x: cropRect.minX, y: cropRect.midY)) { return .left }
140
- if cropRect.contains(point) { return .move }
141
- return nil
85
+ lastPoint = point; lastDistance = distance; reportCrop(); setNeedsDisplay()
142
86
  }
143
-
144
- private func applyDrag(_ handle: Handle, dx: CGFloat, dy: CGFloat) {
145
- var rect = cropRect
146
- switch handle {
147
- case .move:
148
- rect = rect.offsetBy(dx: dx, dy: dy)
149
- case .topLeft:
150
- rect = CGRect(x: rect.minX + dx, y: rect.minY + dy, width: rect.width - dx, height: rect.height - dy)
151
- case .topRight:
152
- rect = CGRect(x: rect.minX, y: rect.minY + dy, width: rect.width + dx, height: rect.height - dy)
153
- case .bottomLeft:
154
- rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height + dy)
155
- case .bottomRight:
156
- rect = CGRect(x: rect.minX, y: rect.minY, width: rect.width + dx, height: rect.height + dy)
157
- case .top: rect = CGRect(x: rect.minX, y: rect.minY + dy, width: rect.width, height: rect.height - dy)
158
- case .right: rect.size.width += dx
159
- case .bottom: rect.size.height += dy
160
- case .left: rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height)
161
- }
162
- guard rect.width >= minSize, rect.height >= minSize else { return }
163
- if let ratio = aspectRatio {
164
- switch handle {
165
- case .topLeft, .bottomLeft, .bottomRight:
166
- rect.size.height = rect.width / ratio
167
- case .topRight:
168
- let newHeight = rect.width / ratio
169
- rect.origin.y = cropRect.maxY - newHeight
170
- rect.size.height = newHeight
171
- case .move:
172
- break
173
- case .left, .right:
174
- let centerY = cropRect.midY; rect.size.height = rect.width / ratio; rect.origin.y = centerY - rect.height / 2
175
- case .top, .bottom:
176
- let centerX = cropRect.midX; rect.size.width = rect.height * ratio; rect.origin.x = centerX - rect.width / 2
177
- }
178
- }
179
- cropRect = rect
180
- clampToImageBounds()
87
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
88
+ if let secondary, touches.contains(secondary) { self.secondary = nil }
89
+ if let primary, touches.contains(primary) { self.primary = secondary; secondary = nil }
90
+ let metrics = touchMetrics(); lastPoint = metrics.0; lastDistance = metrics.1
91
+ if primary == nil { activeHandle = nil }
181
92
  }
182
-
183
- private func clampToImageBounds() {
184
- if cropRect.minX < imageBounds.minX { cropRect.origin.x = imageBounds.minX }
185
- if cropRect.minY < imageBounds.minY { cropRect.origin.y = imageBounds.minY }
186
- if cropRect.maxX > imageBounds.maxX { cropRect.origin.x = imageBounds.maxX - cropRect.width }
187
- if cropRect.maxY > imageBounds.maxY { cropRect.origin.y = imageBounds.maxY - cropRect.height }
93
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { primary = nil; secondary = nil; activeHandle = nil }
94
+
95
+ private func moveMedia(dx:CGFloat,dy:CGFloat,factor:CGFloat,focus:CGPoint) {
96
+ let minimum = max(1,max(cropRect.width/fittedBounds.width,cropRect.height/fittedBounds.height))
97
+ let zoom = imageBounds.width/fittedBounds.width
98
+ let scale = max(minimum,min(max(8,minimum),zoom*factor))/zoom
99
+ let w = imageBounds.width*scale, h = imageBounds.height*scale
100
+ let x = max(cropRect.maxX-w,min(cropRect.minX,focus.x+(imageBounds.minX-focus.x)*scale+dx))
101
+ let y = max(cropRect.maxY-h,min(cropRect.minY,focus.y+(imageBounds.minY-focus.y)*scale+dy))
102
+ imageBounds = CGRect(x:x,y:y,width:w,height:h)
103
+ onMediaBoundsChanged?(imageBounds)
104
+ onViewportChanged?(w/fittedBounds.width,(imageBounds.midX-fittedBounds.midX)/fittedBounds.width,(imageBounds.midY-fittedBounds.midY)/fittedBounds.height)
188
105
  }
189
-
190
- private func reportCrop() {
191
- guard imageBounds.width > 0, imageBounds.height > 0 else { return }
192
- let left = (cropRect.minX - imageBounds.minX) / imageBounds.width
193
- let top = (cropRect.minY - imageBounds.minY) / imageBounds.height
194
- let right = (cropRect.maxX - imageBounds.minX) / imageBounds.width
195
- let bottom = (cropRect.maxY - imageBounds.minY) / imageBounds.height
196
- onCropChanged?(left, top, right, bottom)
106
+ private func touchMetrics() -> (CGPoint,CGFloat) {
107
+ guard let a = primary?.location(in:self) else { return (.zero,0) }
108
+ guard let b = secondary?.location(in:self) else { return (a,0) }
109
+ return (CGPoint(x:(a.x+b.x)/2,y:(a.y+b.y)/2),hypot(a.x-b.x,a.y-b.y))
110
+ }
111
+ private func hitHandle(_ p:CGPoint) -> CropGeometry.Handle? {
112
+ let points: [(CGPoint,CropGeometry.Handle)] = [
113
+ (CGPoint(x:cropRect.minX,y:cropRect.minY),.topLeft),(CGPoint(x:cropRect.maxX,y:cropRect.minY),.topRight),
114
+ (CGPoint(x:cropRect.minX,y:cropRect.maxY),.bottomLeft),(CGPoint(x:cropRect.maxX,y:cropRect.maxY),.bottomRight),
115
+ (CGPoint(x:cropRect.midX,y:cropRect.minY),.top),(CGPoint(x:cropRect.maxX,y:cropRect.midY),.right),
116
+ (CGPoint(x:cropRect.midX,y:cropRect.maxY),.bottom),(CGPoint(x:cropRect.minX,y:cropRect.midY),.left)]
117
+ guard let nearest = points.min(by: { hypot(p.x-$0.0.x,p.y-$0.0.y) < hypot(p.x-$1.0.x,p.y-$1.0.y) }), hypot(p.x-nearest.0.x,p.y-nearest.0.y) <= 24 else { return nil }
118
+ return nearest.1
119
+ }
120
+ private func normalized() -> CGRect {
121
+ guard imageBounds.width > 0,imageBounds.height > 0 else { return CGRect(x:0,y:0,width:1,height:1) }
122
+ return CGRect(x:(cropRect.minX-imageBounds.minX)/imageBounds.width,y:(cropRect.minY-imageBounds.minY)/imageBounds.height,width:cropRect.width/imageBounds.width,height:cropRect.height/imageBounds.height)
197
123
  }
124
+ private func reportCrop() { let r = normalized(); onCropChanged?(max(0,r.minX),max(0,r.minY),min(1,r.maxX),min(1,r.maxY)) }
198
125
  }
@@ -0,0 +1,62 @@
1
+ import UIKit
2
+
3
+ /// Loads filter-carousel previews off the main thread, mirroring `FilterThumbnailLoader.kt`.
4
+ ///
5
+ /// The carousel offers a preset per look, so rendering each one from the full-resolution photo would
6
+ /// stall the panel every time it opens. Each preset is instead applied to one small square scaled
7
+ /// from the source, and the result is cached by preset id for the life of the editor.
8
+ final class FilterThumbnailLoader {
9
+ private let queue = DispatchQueue(label: "filter-thumbnail-loader", qos: .userInitiated)
10
+ private let cache = NSCache<NSString, UIImage>()
11
+ private var scaledSource: UIImage?
12
+ private var scaledSourceSize: CGFloat = 0
13
+
14
+ func load(source: UIImage?, presetID: String, size: CGFloat, deliver: @escaping (UIImage?) -> Void) {
15
+ guard let source else {
16
+ deliver(nil)
17
+ return
18
+ }
19
+ if let cached = cache.object(forKey: presetID as NSString) {
20
+ deliver(cached)
21
+ return
22
+ }
23
+ // Scale once and reuse for every preset rather than per thumbnail.
24
+ let square = squareSource(from: source, size: size)
25
+ queue.async { [weak self] in
26
+ let rendered = PhotoAdjustmentRenderer.apply(
27
+ square,
28
+ adjustments: PhotoAdjustments(filterPreset: presetID, filterStrength: 100)
29
+ )
30
+ self?.cache.setObject(rendered, forKey: presetID as NSString)
31
+ DispatchQueue.main.async { deliver(rendered) }
32
+ }
33
+ }
34
+
35
+ private func squareSource(from source: UIImage, size: CGFloat) -> UIImage {
36
+ if let scaledSource, scaledSourceSize == size { return scaledSource }
37
+ let scale = UIScreen.main.scale
38
+ let pixelSize = CGSize(width: size * scale, height: size * scale)
39
+ let renderer = PhotoEditSession.pixelRenderer(size: pixelSize)
40
+ // Aspect-fill into a square so each thumbnail frames the photo the same way.
41
+ let fitted = renderer.image { _ in
42
+ let sourceSize = source.size
43
+ let ratio = max(pixelSize.width / max(sourceSize.width, 1), pixelSize.height / max(sourceSize.height, 1))
44
+ let drawSize = CGSize(width: sourceSize.width * ratio, height: sourceSize.height * ratio)
45
+ source.draw(in: CGRect(
46
+ x: (pixelSize.width - drawSize.width) / 2,
47
+ y: (pixelSize.height - drawSize.height) / 2,
48
+ width: drawSize.width,
49
+ height: drawSize.height
50
+ ))
51
+ }
52
+ scaledSource = fitted
53
+ scaledSourceSize = size
54
+ return fitted
55
+ }
56
+
57
+ func invalidate() {
58
+ cache.removeAllObjects()
59
+ scaledSource = nil
60
+ scaledSourceSize = 0
61
+ }
62
+ }
@@ -8,6 +8,7 @@ final class LayerOverlayView: UIView {
8
8
  var maxScale: CGFloat = 8
9
9
  var layers: [PhotoLayer] = [] { didSet { setNeedsDisplay() } }
10
10
  var selectedLayerID: String? { didSet { setNeedsDisplay() } }
11
+ var onLayerDelete: ((String) -> Void)?
11
12
  var onLayerTapped: ((String?) -> Void)?
12
13
  var onLayerDoubleTapped: ((String) -> Void)?
13
14
  var onLayerTransformChanged: ((_ id: String, _ x: CGFloat, _ y: CGFloat, _ scale: CGFloat, _ rotationDegrees: CGFloat) -> Void)?
@@ -42,19 +43,24 @@ final class LayerOverlayView: UIView {
42
43
  context.translateBy(x: center.x, y: center.y)
43
44
  context.rotate(by: layer.rotationDegrees * .pi / 180)
44
45
  context.setStrokeColor(UIColor.white.cgColor)
45
- context.setLineWidth(2)
46
- context.setLineDash(phase: 0, lengths: [8, 5])
46
+ context.setLineWidth(1)
47
47
  context.stroke(CGRect(x: -half.width, y: -half.height, width: half.width * 2, height: half.height * 2))
48
48
  context.setLineDash(phase: 0, lengths: [])
49
49
  context.setFillColor(UIColor.white.cgColor)
50
- context.fillEllipse(in: CGRect(x: half.width - handleRadius, y: half.height - handleRadius, width: handleRadius * 2, height: handleRadius * 2))
51
- context.setStrokeColor(UIColor(red: 87/255, green: 55/255, blue: 245/255, alpha: 1).cgColor)
52
- context.setLineWidth(2)
53
- let glyph = handleRadius * 0.52
54
- context.move(to: CGPoint(x: half.width - glyph, y: half.height + glyph)); context.addLine(to: CGPoint(x: half.width + glyph, y: half.height - glyph)); context.strokePath()
50
+ for point in [CGPoint(x: -half.width, y: -half.height), CGPoint(x: half.width, y: -half.height), CGPoint(x: -half.width, y: half.height), CGPoint(x: half.width, y: half.height)] {
51
+ context.fillEllipse(in: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8))
52
+ }
53
+ drawControl(context, point: CGPoint(x: -half.width, y: -half.height), symbol: "xmark", tint: .systemRed)
54
+ drawControl(context, point: CGPoint(x: half.width, y: half.height), symbol: "arrow.up.left.and.arrow.down.right", tint: DesignTokens.primary)
55
55
  context.restoreGState()
56
56
  }
57
57
 
58
+ private func drawControl(_ context: CGContext, point: CGPoint, symbol: String, tint: UIColor) {
59
+ context.setFillColor(UIColor.white.cgColor)
60
+ context.fillEllipse(in: CGRect(x: point.x - handleRadius, y: point.y - handleRadius, width: handleRadius * 2, height: handleRadius * 2))
61
+ UIImage(systemName: symbol)?.withTintColor(tint, renderingMode: .alwaysOriginal).draw(in: CGRect(x: point.x - 7, y: point.y - 7, width: 14, height: 14))
62
+ }
63
+
58
64
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
59
65
  guard imageBounds.width > 0 else { return }
60
66
  for touch in touches {
@@ -71,6 +77,11 @@ final class LayerOverlayView: UIView {
71
77
  return
72
78
  }
73
79
  let selected = selectedLayer()
80
+ if let selected, !selected.locked {
81
+ let center = center(of: selected); let handle = handlePoint(selected)
82
+ let deletePoint = CGPoint(x: center.x * 2 - handle.x, y: center.y * 2 - handle.y)
83
+ if distance(deletePoint, point) <= handleTouchRadius { onLayerDelete?(selected.id); resetGesture(); return }
84
+ }
74
85
  let handleHit = selected.flatMap { !$0.locked && isHandleHit($0, point) ? $0 : nil }
75
86
  let hit = handleHit ?? hitTest(point)
76
87
  onLayerTapped?(hit?.id)
@@ -117,7 +128,13 @@ final class LayerOverlayView: UIView {
117
128
  private func endTouches(_ touches: Set<UITouch>) {
118
129
  let endedPrimary = primaryTouch.map { touches.contains($0) } ?? false
119
130
  let endedSecondary = secondaryTouch.map { touches.contains($0) } ?? false
120
- if endedPrimary || endedSecondary { finishGesture() }
131
+ if endedPrimary || endedSecondary {
132
+ let remaining = endedPrimary ? secondaryTouch : primaryTouch
133
+ if let remaining, !touches.contains(remaining), let layer = currentGestureLayer() {
134
+ primaryTouch = remaining; secondaryTouch = nil
135
+ startLayer = layer; startPoint = remaining.location(in: self); gesture = .drag
136
+ } else { finishGesture() }
137
+ }
121
138
  }
122
139
  private func emit(_ layer: PhotoLayer, x: CGFloat, y: CGFloat, scale: CGFloat, rotation: CGFloat) {
123
140
  onLayerTransformChanged?(layer.id, x, y, scale, rotation)
@@ -145,7 +162,7 @@ final class LayerOverlayView: UIView {
145
162
  case .sticker:
146
163
  // Mirrors `PhotoLayerRenderer.drawSticker`'s `shortSide * 0.18` box (half-extent = 0.09).
147
164
  let half = shortSide * 0.09
148
- base = CGSize(width: half, height: half)
165
+ base = CGSize(width: half + 4, height: half / max(layer.overlayAspectRatio ?? 1, 0.001) + 4)
149
166
  case .overlay:
150
167
  // Mirrors `PhotoLayerRenderer.drawOverlay`'s box: full canvas width, height from aspect ratio.
151
168
  let aspectRatio = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? 1
@@ -155,7 +172,7 @@ final class LayerOverlayView: UIView {
155
172
  let maxY = layer.drawPoints.map { abs($0.1) }.max() ?? 0.08
156
173
  base = CGSize(width: maxX * imageBounds.width + handleRadius, height: maxY * imageBounds.height + handleRadius)
157
174
  }
158
- return CGSize(width: max(handleRadius, base.width * layer.scale), height: max(handleRadius, base.height * layer.scale))
175
+ return CGSize(width: max(24, base.width * layer.scale), height: max(24, base.height * layer.scale))
159
176
  }
160
177
  private func handlePoint(_ layer: PhotoLayer) -> CGPoint {
161
178
  let center = center(of: layer); let half = selectionHalfExtents(layer); let radians = layer.rotationDegrees * .pi / 180
@@ -1,4 +1,5 @@
1
1
  import UIKit
2
+ import AVFoundation
2
3
 
3
4
  /// UIImageView supporting pinch-to-zoom and single-finger pan for the photo
4
5
  /// preview, reporting the current on-screen image rect via `onBoundsChanged`
@@ -14,6 +15,7 @@ final class ZoomableImageView: UIImageView {
14
15
 
15
16
  private let pinchRecognizer = UIPinchGestureRecognizer()
16
17
  private let panRecognizer = UIPanGestureRecognizer()
18
+ private var lastLayoutSize: CGSize = .zero
17
19
  private var minScale: CGFloat = 1
18
20
  private var maxScale: CGFloat = 4
19
21
 
@@ -37,12 +39,28 @@ final class ZoomableImageView: UIImageView {
37
39
  reportBounds()
38
40
  }
39
41
 
42
+ func setRenderedBounds(_ target: CGRect) {
43
+ guard let image, bounds.width > 0, bounds.height > 0 else { return }
44
+ let fitted = AVMakeRect(aspectRatio: image.size, insideRect: bounds)
45
+ let scale = target.width / max(fitted.width, 0.001)
46
+ transform = CGAffineTransform(translationX: target.midX - center.x, y: target.midY - center.y).scaledBy(x: scale, y: scale)
47
+ }
48
+
40
49
  func currentImageBounds() -> CGRect {
41
50
  guard let image = image, bounds.width > 0, bounds.height > 0, image.size.width > 0, image.size.height > 0 else { return bounds }
42
51
  let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
43
52
  let fittedSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
44
53
  let origin = CGPoint(x: (bounds.width - fittedSize.width) / 2, y: (bounds.height - fittedSize.height) / 2)
45
- return CGRect(origin: origin, size: fittedSize).applying(transform)
54
+ let rect = CGRect(origin: origin, size: fittedSize)
55
+ return superview.map { convert(rect, to: $0) } ?? rect
56
+ }
57
+
58
+ override func layoutSubviews() {
59
+ super.layoutSubviews()
60
+ if bounds.size != lastLayoutSize {
61
+ lastLayoutSize = bounds.size
62
+ resetToFit()
63
+ }
46
64
  }
47
65
 
48
66
  private func reportBounds() {