react-native-photo-video-editor 0.1.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 (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,45 @@
1
+ import Foundation
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`.
7
+ struct PhotoFilterRecipe {
8
+ let scale: Double
9
+ let redOffset: Double
10
+ let greenOffset: Double
11
+ let blueOffset: Double
12
+ let saturation: Double
13
+ }
14
+
15
+ enum PhotoFilterPresets {
16
+ static let presetIDs = ["mono", "noir", "fade", "chrome", "warmth"]
17
+
18
+ static func label(for id: String) -> String {
19
+ switch id {
20
+ case "mono": return "Mono"
21
+ case "noir": return "Noir"
22
+ case "fade": return "Fade"
23
+ case "chrome": return "Chrome"
24
+ case "warmth": return "Warmth"
25
+ default: return id
26
+ }
27
+ }
28
+
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
+ }
45
+ }
@@ -0,0 +1,59 @@
1
+ import UIKit
2
+
3
+ enum LayerType {
4
+ case text, sticker, overlay
5
+ }
6
+
7
+ /// A single non-destructive overlay layer (text, sticker, or a user-uploaded
8
+ /// overlay image). One flat model covers every type for simplicity — a
9
+ /// documented simplification versus a per-type hierarchy. Mirrors
10
+ /// `PhotoLayer.kt`.
11
+ ///
12
+ /// `x`/`y` are the layer's center, normalized 0..1 against the image.
13
+ struct PhotoLayer {
14
+ let id: String
15
+ var type: LayerType
16
+ var x: CGFloat = 0.5
17
+ var y: CGFloat = 0.5
18
+ var scale: CGFloat = 1
19
+ var rotationDegrees: CGFloat = 0
20
+ var opacity: CGFloat = 1
21
+ var locked = false
22
+ var visible = true
23
+ // Text
24
+ var text: String = ""
25
+ var textColor: UIColor = .white
26
+ var fontSize: CGFloat = 48
27
+ var fontFamily: String?
28
+ // Sticker
29
+ var stickerId: String?
30
+ var stickerUri: String?
31
+ // Overlay (user-uploaded image, placed as a fully generic movable/resizable/rotatable layer)
32
+ var overlayUri: String?
33
+ var overlayAspectRatio: CGFloat?
34
+ // Timing (video overlays only; ignored for photo layers). endMs <= 0 means "to end of video".
35
+ var startMs: Int64 = 0
36
+ var endMs: Int64 = 0
37
+
38
+ init(id: String = UUID().uuidString, type: LayerType) {
39
+ self.id = id
40
+ self.type = type
41
+ }
42
+
43
+ /// True if this layer should be visible at `positionMs` against a video of `durationMs`. Always true for photo layers.
44
+ func effectiveEndMs(durationMs: Int64) -> Int64 {
45
+ (endMs > 0 && endMs <= durationMs) ? endMs : durationMs
46
+ }
47
+
48
+ func isActive(atMs positionMs: Int64, durationMs: Int64) -> Bool {
49
+ guard visible, durationMs >= 0 else { return false }
50
+ let clamped = max(positionMs, 0)
51
+ guard clamped >= max(startMs, 0) else { return false }
52
+ // No explicit end (or one at/past the video's end) means "visible through the real last
53
+ // frame" — don't compare against `durationMs`, which is a separately-computed estimate
54
+ // that can be a few ms shy of the true final frame's timestamp due to frame-rate rounding,
55
+ // which would otherwise hide the overlay early.
56
+ if endMs <= 0 || endMs >= durationMs { return true }
57
+ return clamped <= endMs
58
+ }
59
+ }
@@ -0,0 +1,94 @@
1
+ import UIKit
2
+
3
+ /// Burns the layer stack onto a `UIImage`. Used identically by the live
4
+ /// preview and the exporter (different image resolutions, same code and
5
+ /// normalized coordinates) so what you see matches what gets exported.
6
+ /// Mirrors `PhotoLayerRenderer.kt`.
7
+ enum PhotoLayerRenderer {
8
+ private static let builtinStickers: [String: String] = [
9
+ "smile": "😀", "heart": "❤️", "star": "⭐", "fire": "🔥",
10
+ "thumbsUp": "👍", "sun": "☀️", "clap": "👏", "sparkles": "✨",
11
+ ]
12
+
13
+ static func builtinStickerIDs() -> [String] { Array(builtinStickers.keys).sorted() }
14
+ static func glyph(for stickerID: String) -> String { builtinStickers[stickerID] ?? "★" }
15
+
16
+ static func render(_ base: UIImage, layers: [PhotoLayer], imageResolver: (String) -> UIImage? = { _ in nil }) -> UIImage {
17
+ guard !layers.isEmpty else { return base }
18
+ let renderer = PhotoEditSession.pixelRenderer(size: base.size)
19
+ return renderer.image { context in
20
+ base.draw(in: CGRect(origin: .zero, size: base.size))
21
+ for layer in layers where layer.visible {
22
+ let geometry = OverlayGeometry.map(layer, frameSize: base.size)
23
+ let ctx = context.cgContext
24
+ ctx.saveGState()
25
+ ctx.translateBy(x: geometry.center.x, y: geometry.center.y)
26
+ ctx.rotate(by: geometry.rotationDegrees * .pi / 180)
27
+ ctx.scaleBy(x: geometry.scale, y: geometry.scale)
28
+ switch layer.type {
29
+ case .text: drawText(layer, shortSide: geometry.shortSide)
30
+ case .sticker: drawSticker(layer, shortSide: geometry.shortSide, resolver: imageResolver)
31
+ case .overlay: drawOverlay(layer, canvasWidth: base.size.width, resolver: imageResolver)
32
+ }
33
+ ctx.restoreGState()
34
+ }
35
+ }
36
+ }
37
+
38
+ private static func drawText(_ layer: PhotoLayer, shortSide: CGFloat) {
39
+ let fontSize = layer.fontSize / 48 * (shortSide * 0.06)
40
+ let font: UIFont = layer.fontFamily.flatMap { UIFont(name: $0, size: fontSize) } ?? .systemFont(ofSize: fontSize)
41
+ let paragraph = NSMutableParagraphStyle()
42
+ paragraph.alignment = .center
43
+ let attributes: [NSAttributedString.Key: Any] = [
44
+ .font: font,
45
+ .foregroundColor: layer.textColor.withAlphaComponent(layer.opacity),
46
+ .paragraphStyle: paragraph,
47
+ ]
48
+ let text = layer.text.isEmpty ? " " : layer.text
49
+ let attributed = NSAttributedString(string: text, attributes: attributes)
50
+ let size = attributed.size()
51
+ attributed.draw(at: CGPoint(x: -size.width / 2, y: -size.height / 2))
52
+ }
53
+
54
+ private static func drawSticker(_ layer: PhotoLayer, shortSide: CGFloat, resolver: (String) -> UIImage?) {
55
+ let size = shortSide * 0.18
56
+ let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
57
+ if let uri = layer.stickerUri, let image = resolver(uri) {
58
+ image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
59
+ return
60
+ }
61
+ let glyph = layer.stickerId.map { PhotoLayerRenderer.glyph(for: $0) } ?? "★"
62
+ let attributes: [NSAttributedString.Key: Any] = [
63
+ .font: UIFont.systemFont(ofSize: size),
64
+ ]
65
+ let attributed = NSAttributedString(string: glyph, attributes: attributes)
66
+ let textSize = attributed.size()
67
+ attributed.draw(at: CGPoint(x: -textSize.width / 2, y: -size / 2), withAlpha: layer.opacity)
68
+ }
69
+
70
+ /// Draws a user-uploaded overlay image scaled to fit a box whose width equals the full canvas
71
+ /// width and whose height is derived from `overlayAspectRatio` — the layer's own `scale` (already
72
+ /// applied to the drawing context by `render`) then shrinks/grows it from there. Unlike stickers,
73
+ /// there is no glyph fallback: if the resolver can't produce an image, nothing is drawn.
74
+ private static func drawOverlay(_ layer: PhotoLayer, canvasWidth: CGFloat, resolver: (String) -> UIImage?) {
75
+ guard let uri = layer.overlayUri, let image = resolver(uri) else { return }
76
+ let aspectRatio = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? (image.size.width / max(image.size.height, 1))
77
+ let width = canvasWidth
78
+ let height = width / max(aspectRatio, 0.0001)
79
+ let rect = CGRect(x: -width / 2, y: -height / 2, width: width, height: height)
80
+ image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
81
+ }
82
+
83
+ }
84
+
85
+ private extension NSAttributedString {
86
+ /// `draw(at:)` ignores `withAlpha` unless applied via the graphics context; this composites the whole glyph at `alpha`.
87
+ func draw(at point: CGPoint, withAlpha alpha: CGFloat) {
88
+ guard let ctx = UIGraphicsGetCurrentContext() else { draw(at: point); return }
89
+ ctx.saveGState()
90
+ ctx.setAlpha(alpha)
91
+ draw(at: point)
92
+ ctx.restoreGState()
93
+ }
94
+ }
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+
3
+ /// Ordered layer stack with snapshot-based undo/redo. One entry is pushed per
4
+ /// *committed* operation (add/delete/duplicate/reorder, or a drag/slider
5
+ /// gesture's final value) — never per intermediate touch-move frame — so a
6
+ /// continuous drag does not flood the history. Mirrors `PhotoLayerStack.kt`.
7
+ final class PhotoLayerStack {
8
+ private(set) var layers: [PhotoLayer] = []
9
+ private(set) var revision: UInt64 = 0
10
+ private var undoStack: [[PhotoLayer]] = []
11
+ private var redoStack: [[PhotoLayer]] = []
12
+ private let historyLimit: Int
13
+
14
+ init(historyLimit: Int = 50) {
15
+ self.historyLimit = historyLimit
16
+ }
17
+
18
+ var canUndo: Bool { !undoStack.isEmpty }
19
+ var canRedo: Bool { !redoStack.isEmpty }
20
+
21
+ /// Applies `mutation` as a single committed, undoable operation.
22
+ func commit(_ mutation: ([PhotoLayer]) -> [PhotoLayer]) {
23
+ pushUndo(layers)
24
+ redoStack.removeAll()
25
+ layers = mutation(layers)
26
+ revision &+= 1
27
+ }
28
+
29
+ /// Updates the live layer list without creating a history entry — for in-progress drags.
30
+ func updateLive(_ mutation: ([PhotoLayer]) -> [PhotoLayer]) {
31
+ layers = mutation(layers)
32
+ revision &+= 1
33
+ }
34
+
35
+ /// Records `previousLayers` (captured before a live-updated gesture began) as one undo step.
36
+ func commitSnapshot(_ previousLayers: [PhotoLayer]) {
37
+ pushUndo(previousLayers)
38
+ redoStack.removeAll()
39
+ revision &+= 1
40
+ }
41
+
42
+ func undo() {
43
+ guard let previous = undoStack.popLast() else { return }
44
+ redoStack.append(layers)
45
+ layers = previous
46
+ revision &+= 1
47
+ }
48
+
49
+ func redo() {
50
+ guard let next = redoStack.popLast() else { return }
51
+ undoStack.append(layers)
52
+ layers = next
53
+ revision &+= 1
54
+ }
55
+
56
+ private func pushUndo(_ snapshot: [PhotoLayer]) {
57
+ undoStack.append(snapshot)
58
+ if undoStack.count > historyLimit { undoStack.removeFirst() }
59
+ }
60
+ }
@@ -0,0 +1,49 @@
1
+ import CoreGraphics
2
+
3
+ /// Non-destructive photo edit state. The crop rectangle is normalized (0...1)
4
+ /// against the *transformed* (rotated/straightened) image, matching
5
+ /// what the user sees in the crop overlay — the original source file is never
6
+ /// modified, only read again at export time.
7
+ struct PhotoTransformState: Equatable {
8
+ var cropLeft: CGFloat = 0
9
+ var cropTop: CGFloat = 0
10
+ var cropRight: CGFloat = 1
11
+ var cropBottom: CGFloat = 1
12
+ var rotationDegrees: Int = 0
13
+ var straightenDegrees: CGFloat = 0
14
+ var aspectRatio: CGFloat?
15
+
16
+ var isIdentity: Bool {
17
+ cropLeft == 0 && cropTop == 0 && cropRight == 1 && cropBottom == 1 &&
18
+ rotationDegrees == 0 && straightenDegrees == 0
19
+ }
20
+
21
+ /// Rotating changes which pixels a normalized crop rect refers to, so the crop resets to full-frame.
22
+ mutating func rotateRight() {
23
+ rotationDegrees = (rotationDegrees + 90) % 360
24
+ cropLeft = 0
25
+ cropTop = 0
26
+ cropRight = 1
27
+ cropBottom = 1
28
+ }
29
+
30
+ mutating func setStraighten(_ degrees: CGFloat) {
31
+ straightenDegrees = max(-45, min(45, degrees))
32
+ }
33
+
34
+ mutating func setCrop(left: CGFloat, top: CGFloat, right: CGFloat, bottom: CGFloat) {
35
+ cropLeft = max(0, min(1, left))
36
+ cropTop = max(0, min(1, top))
37
+ cropRight = max(0, min(1, right))
38
+ cropBottom = max(0, min(1, bottom))
39
+ }
40
+
41
+ mutating func reset() {
42
+ self = PhotoTransformState()
43
+ }
44
+
45
+ /// Combined rotate + straighten radians, applied around the image's own center by the caller.
46
+ var totalRotationRadians: CGFloat {
47
+ (CGFloat(rotationDegrees) + straightenDegrees) * .pi / 180
48
+ }
49
+ }
@@ -0,0 +1,68 @@
1
+ import CoreGraphics
2
+ import UIKit
3
+
4
+ /// Raw RGBA8 pixel access for a `UIImage`, used by adjustment/effect passes
5
+ /// that need direct per-pixel math (tone curve, sharpen, box blur).
6
+ ///
7
+ /// Important: a bitmap `CGContext` created via `CGContext(data:...)` has its
8
+ /// origin at the bottom-left with Y increasing upward (Quartz-native), which
9
+ /// is the OPPOSITE of the top-down row order `CGImage`/`UIImage` assume.
10
+ /// Drawing into it without first flipping the CTM produces a vertically
11
+ /// flipped image once the raw bytes are wrapped back into a `CGImage`. Both
12
+ /// `init` and `makeImage()` apply that flip so round-tripping through this
13
+ /// buffer preserves the image's orientation.
14
+ struct PixelBuffer {
15
+ private(set) var data: [UInt8]
16
+ let width: Int
17
+ let height: Int
18
+ private static let bytesPerPixel = 4
19
+ private static let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
20
+
21
+ init?(image: UIImage) {
22
+ guard let cgImage = image.cgImage, cgImage.width > 0, cgImage.height > 0 else { return nil }
23
+ width = cgImage.width
24
+ height = cgImage.height
25
+ var buffer = [UInt8](repeating: 0, count: width * height * Self.bytesPerPixel)
26
+ guard let context = CGContext(
27
+ data: &buffer,
28
+ width: width,
29
+ height: height,
30
+ bitsPerComponent: 8,
31
+ bytesPerRow: width * Self.bytesPerPixel,
32
+ space: CGColorSpaceCreateDeviceRGB(),
33
+ bitmapInfo: Self.bitmapInfo
34
+ ) else { return nil }
35
+ context.translateBy(x: 0, y: CGFloat(height))
36
+ context.scaleBy(x: 1, y: -1)
37
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
38
+ data = buffer
39
+ }
40
+
41
+ func makeImage() -> UIImage? {
42
+ var buffer = data
43
+ guard let context = CGContext(
44
+ data: &buffer,
45
+ width: width,
46
+ height: height,
47
+ bitsPerComponent: 8,
48
+ bytesPerRow: width * Self.bytesPerPixel,
49
+ space: CGColorSpaceCreateDeviceRGB(),
50
+ bitmapInfo: Self.bitmapInfo
51
+ ), let cgImage = context.makeImage() else { return nil }
52
+ return UIImage(cgImage: cgImage)
53
+ }
54
+
55
+ subscript(x: Int, y: Int) -> (r: UInt8, g: UInt8, b: UInt8, a: UInt8) {
56
+ get {
57
+ let offset = (y * width + x) * Self.bytesPerPixel
58
+ return (data[offset], data[offset + 1], data[offset + 2], data[offset + 3])
59
+ }
60
+ set {
61
+ let offset = (y * width + x) * Self.bytesPerPixel
62
+ data[offset] = newValue.r
63
+ data[offset + 1] = newValue.g
64
+ data[offset + 2] = newValue.b
65
+ data[offset + 3] = newValue.a
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,161 @@
1
+ import UIKit
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`.
6
+ 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
11
+ private var aspectRatio: CGFloat?
12
+
13
+ private let dimLayer = CAShapeLayer()
14
+ private let borderLayer = CAShapeLayer()
15
+ private let handleRadius: CGFloat = 32
16
+ private let minSize: CGFloat = 60
17
+
18
+ private enum Handle { case move, topLeft, topRight, bottomLeft, bottomRight }
19
+ private var activeHandle: Handle?
20
+ private var lastPoint: CGPoint = .zero
21
+
22
+ override init(frame: CGRect) {
23
+ super.init(frame: frame)
24
+ backgroundColor = .clear
25
+ dimLayer.fillRule = .evenOdd
26
+ dimLayer.fillColor = UIColor.black.withAlphaComponent(0.6).cgColor
27
+ layer.addSublayer(dimLayer)
28
+ borderLayer.strokeColor = UIColor.white.cgColor
29
+ borderLayer.fillColor = UIColor.clear.cgColor
30
+ borderLayer.lineWidth = 2
31
+ layer.addSublayer(borderLayer)
32
+ }
33
+
34
+ required init?(coder: NSCoder) { nil }
35
+
36
+ /// Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when `resetCrop` is true.
37
+ func setImageBounds(_ bounds: CGRect, resetCrop: Bool) {
38
+ imageBounds = bounds
39
+ if resetCrop || cropRect.width <= 0 || cropRect.height <= 0 {
40
+ cropRect = bounds
41
+ reportCrop()
42
+ }
43
+ setNeedsLayout()
44
+ }
45
+
46
+ func setAspectRatio(_ ratio: CGFloat?) {
47
+ aspectRatio = ratio
48
+ if let ratio, imageBounds.width > 0, imageBounds.height > 0 {
49
+ let center = CGPoint(x: cropRect.midX, y: cropRect.midY)
50
+ var width = cropRect.width
51
+ var height = width / ratio
52
+ if height > imageBounds.height {
53
+ height = cropRect.height
54
+ width = height * ratio
55
+ }
56
+ cropRect = CGRect(x: center.x - width / 2, y: center.y - height / 2, width: width, height: height)
57
+ clampToImageBounds()
58
+ setNeedsLayout()
59
+ }
60
+ reportCrop()
61
+ }
62
+
63
+ override func layoutSublayers(of layer: CALayer) {
64
+ super.layoutSublayers(of: layer)
65
+ guard imageBounds.width > 0, imageBounds.height > 0 else { return }
66
+ let outerPath = UIBezierPath(rect: imageBounds)
67
+ outerPath.append(UIBezierPath(rect: cropRect).reversing())
68
+ dimLayer.path = outerPath.cgPath
69
+
70
+ let borderPath = UIBezierPath(rect: cropRect)
71
+ let thirdWidth = cropRect.width / 3
72
+ let thirdHeight = cropRect.height / 3
73
+ for index in 1...2 {
74
+ let x = cropRect.minX + thirdWidth * CGFloat(index)
75
+ borderPath.move(to: CGPoint(x: x, y: cropRect.minY))
76
+ borderPath.addLine(to: CGPoint(x: x, y: cropRect.maxY))
77
+ let y = cropRect.minY + thirdHeight * CGFloat(index)
78
+ borderPath.move(to: CGPoint(x: cropRect.minX, y: y))
79
+ borderPath.addLine(to: CGPoint(x: cropRect.maxX, y: y))
80
+ }
81
+ borderLayer.path = borderPath.cgPath
82
+ }
83
+
84
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
85
+ guard let point = touches.first?.location(in: self) else { return }
86
+ activeHandle = hitTest(point)
87
+ lastPoint = point
88
+ }
89
+
90
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
91
+ guard let handle = activeHandle, let point = touches.first?.location(in: self) else { return }
92
+ let dx = point.x - lastPoint.x
93
+ let dy = point.y - lastPoint.y
94
+ lastPoint = point
95
+ applyDrag(handle, dx: dx, dy: dy)
96
+ setNeedsLayout()
97
+ reportCrop()
98
+ }
99
+
100
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
101
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
102
+
103
+ private func hitTest(_ point: CGPoint) -> Handle? {
104
+ func near(_ target: CGPoint) -> Bool {
105
+ abs(point.x - target.x) < handleRadius && abs(point.y - target.y) < handleRadius
106
+ }
107
+ if near(CGPoint(x: cropRect.minX, y: cropRect.minY)) { return .topLeft }
108
+ if near(CGPoint(x: cropRect.maxX, y: cropRect.minY)) { return .topRight }
109
+ if near(CGPoint(x: cropRect.minX, y: cropRect.maxY)) { return .bottomLeft }
110
+ if near(CGPoint(x: cropRect.maxX, y: cropRect.maxY)) { return .bottomRight }
111
+ if cropRect.contains(point) { return .move }
112
+ return nil
113
+ }
114
+
115
+ private func applyDrag(_ handle: Handle, dx: CGFloat, dy: CGFloat) {
116
+ var rect = cropRect
117
+ switch handle {
118
+ case .move:
119
+ rect = rect.offsetBy(dx: dx, dy: dy)
120
+ case .topLeft:
121
+ rect = CGRect(x: rect.minX + dx, y: rect.minY + dy, width: rect.width - dx, height: rect.height - dy)
122
+ case .topRight:
123
+ rect = CGRect(x: rect.minX, y: rect.minY + dy, width: rect.width + dx, height: rect.height - dy)
124
+ case .bottomLeft:
125
+ rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height + dy)
126
+ case .bottomRight:
127
+ rect = CGRect(x: rect.minX, y: rect.minY, width: rect.width + dx, height: rect.height + dy)
128
+ }
129
+ guard rect.width >= minSize, rect.height >= minSize else { return }
130
+ if let ratio = aspectRatio {
131
+ switch handle {
132
+ case .topLeft, .bottomLeft, .bottomRight:
133
+ rect.size.height = rect.width / ratio
134
+ case .topRight:
135
+ let newHeight = rect.width / ratio
136
+ rect.origin.y = cropRect.maxY - newHeight
137
+ rect.size.height = newHeight
138
+ case .move:
139
+ break
140
+ }
141
+ }
142
+ cropRect = rect
143
+ clampToImageBounds()
144
+ }
145
+
146
+ private func clampToImageBounds() {
147
+ if cropRect.minX < imageBounds.minX { cropRect.origin.x = imageBounds.minX }
148
+ if cropRect.minY < imageBounds.minY { cropRect.origin.y = imageBounds.minY }
149
+ if cropRect.maxX > imageBounds.maxX { cropRect.origin.x = imageBounds.maxX - cropRect.width }
150
+ if cropRect.maxY > imageBounds.maxY { cropRect.origin.y = imageBounds.maxY - cropRect.height }
151
+ }
152
+
153
+ private func reportCrop() {
154
+ guard imageBounds.width > 0, imageBounds.height > 0 else { return }
155
+ let left = (cropRect.minX - imageBounds.minX) / imageBounds.width
156
+ let top = (cropRect.minY - imageBounds.minY) / imageBounds.height
157
+ let right = (cropRect.maxX - imageBounds.minX) / imageBounds.width
158
+ let bottom = (cropRect.maxY - imageBounds.minY) / imageBounds.height
159
+ onCropChanged?(left, top, right, bottom)
160
+ }
161
+ }