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.
- package/CHANGELOG.md +11 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
- package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
- package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
- package/docs/editor-ui-validation.md +22 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
- package/ios/Editor/UI/CropPanel.swift +97 -0
- package/ios/Editor/UI/EditorPanel.swift +334 -0
- package/ios/Editor/UI/EditorToolSlider.swift +22 -0
- package/ios/Editor/UI/TextEditorSheet.swift +84 -0
- package/ios/Photo/Export/PhotoExporter.swift +3 -3
- package/ios/Photo/Rendering/CropGeometry.swift +48 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
- package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
- package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
- package/ios/Photo/UI/CropOverlayView.swift +96 -169
- package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
- package/ios/Photo/UI/LayerOverlayView.swift +27 -10
- package/ios/Photo/UI/ZoomableImageView.swift +19 -1
- package/ios/Video/Export/VideoExporter.swift +10 -1
- package/ios/Video/Render/VideoCropGeometry.swift +48 -0
- package/ios/Video/Render/VideoTransformState.swift +1 -0
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
-
///
|
|
4
|
-
///
|
|
5
|
-
///
|
|
6
|
-
///
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
///
|
|
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: ((
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
private var
|
|
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
|
|
14
|
-
private
|
|
15
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
]
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
117
|
-
let dx = point.x -
|
|
118
|
-
let
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
guard
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let
|
|
196
|
-
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
context
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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() {
|