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,5 @@
1
+ # Planned iOS editor
2
+
3
+ Milestone one contains no editing engine. A future full-screen editor will use `AVPlayer` for preview, AVFoundation composition/export APIs for timelines and final output, and Core Image/Core Graphics for photo filtering and composition. Metal will be introduced only when measured real-time rendering requirements justify it.
4
+
5
+ Temporary/output URL ownership and cleanup will be explicit. Photos framework integration will occur only when gallery saving is requested; the consuming app—not this library—must provide the relevant usage descriptions.
@@ -0,0 +1,138 @@
1
+ import UIKit
2
+
3
+ /// Shared "Studio Violet" UI primitives (DESIGN.md), reused across the photo/video editor shell
4
+ /// instead of hand-rolling styling per screen. Mirrors `EditorComponents.kt` on Android.
5
+
6
+ /// A `UITapGestureRecognizer` that takes a closure instead of a target/action pair.
7
+ final class ClosureTapGestureRecognizer: UITapGestureRecognizer {
8
+ private let action: () -> Void
9
+
10
+ init(action: @escaping () -> Void) {
11
+ self.action = action
12
+ super.init(target: nil, action: nil)
13
+ addTarget(self, action: #selector(handle))
14
+ }
15
+
16
+ @objc private func handle() { action() }
17
+ }
18
+
19
+ /// Tactile press feedback (scale 1.0 -> 0.97) matching DESIGN.md's "Tactile Instrument Feel".
20
+ /// Only observes touch-down/up via a zero-duration long-press recognizer with
21
+ /// `cancelsTouchesInView = false`, so it never interferes with an existing tap recognizer
22
+ /// (e.g. the tool rail's `handleToolNodeTap`) already attached to the same view.
23
+ private final class EditorPressFeedback: NSObject {
24
+ private let pressedScale: CGFloat
25
+ private weak var target: UIView?
26
+
27
+ init(target: UIView, pressedScale: CGFloat) {
28
+ self.target = target
29
+ self.pressedScale = pressedScale
30
+ super.init()
31
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handle(_:)))
32
+ recognizer.minimumPressDuration = 0
33
+ recognizer.cancelsTouchesInView = false
34
+ target.addGestureRecognizer(recognizer)
35
+ }
36
+
37
+ @objc private func handle(_ recognizer: UILongPressGestureRecognizer) {
38
+ guard let target else { return }
39
+ switch recognizer.state {
40
+ case .began:
41
+ UIView.animate(withDuration: 0.08) { target.transform = CGAffineTransform(scaleX: self.pressedScale, y: self.pressedScale) }
42
+ case .ended, .cancelled, .failed:
43
+ UIView.animate(withDuration: 0.12) { target.transform = .identity }
44
+ default:
45
+ break
46
+ }
47
+ }
48
+ }
49
+
50
+ private var pressFeedbackAssociationKey: UInt8 = 0
51
+
52
+ extension UIView {
53
+ /// Attaches (once) the shared press-scale feedback to this view.
54
+ func applyPressScale(_ scale: CGFloat = 0.97) {
55
+ guard objc_getAssociatedObject(self, &pressFeedbackAssociationKey) == nil else { return }
56
+ let feedback = EditorPressFeedback(target: self, pressedScale: scale)
57
+ objc_setAssociatedObject(self, &pressFeedbackAssociationKey, feedback, .OBJC_ASSOCIATION_RETAIN)
58
+ }
59
+ }
60
+
61
+ enum EditorComponents {
62
+ /// Elevated rounded container (DESIGN.md "Elevated Panels & Tiles"), used for cards/tiles/sheets.
63
+ static func card(
64
+ radius: CGFloat = DesignTokens.radiusLg,
65
+ backgroundColor: UIColor = DesignTokens.elevatedPanel,
66
+ borderColor: UIColor? = DesignTokens.interactiveNeutral
67
+ ) -> UIView {
68
+ let view = UIView()
69
+ view.backgroundColor = backgroundColor
70
+ view.layer.cornerRadius = radius
71
+ if let borderColor {
72
+ view.layer.borderColor = borderColor.cgColor
73
+ view.layer.borderWidth = 1
74
+ }
75
+ return view
76
+ }
77
+
78
+ /// Pill chip for quick actions / filter tags (DESIGN.md "Chips & Filter Presets").
79
+ static func chip(text: String, selected: Bool, onTap: @escaping () -> Void) -> UIView {
80
+ let label = UILabel()
81
+ label.text = text
82
+ label.font = .systemFont(ofSize: 12, weight: .semibold)
83
+ label.textColor = selected ? DesignTokens.textPrimary : DesignTokens.textSecondary
84
+ label.textAlignment = .center
85
+
86
+ let container = UIView()
87
+ container.backgroundColor = selected ? DesignTokens.accentViolet : DesignTokens.elevatedPanel
88
+ container.layer.cornerRadius = 16
89
+ if !selected {
90
+ container.layer.borderColor = DesignTokens.interactiveNeutral.cgColor
91
+ container.layer.borderWidth = 1
92
+ }
93
+ container.addSubview(label)
94
+ label.translatesAutoresizingMaskIntoConstraints = false
95
+ NSLayoutConstraint.activate([
96
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: DesignTokens.spaceLg),
97
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -DesignTokens.spaceLg),
98
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: DesignTokens.spaceXs),
99
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -DesignTokens.spaceXs),
100
+ container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
101
+ ])
102
+ container.isUserInteractionEnabled = true
103
+ container.addGestureRecognizer(ClosureTapGestureRecognizer(action: onTap))
104
+ container.applyPressScale()
105
+ return container
106
+ }
107
+
108
+ /// Primary Export CTA (DESIGN.md "Primary Export Button"): solid violet fill, 44pt min height,
109
+ /// 12px radius, subtle glow shadow, 0.97 press scale.
110
+ static func primaryButton(
111
+ text: String,
112
+ systemImage: String? = nil,
113
+ fillColor: UIColor = DesignTokens.accentViolet,
114
+ textColor: UIColor = DesignTokens.textPrimary,
115
+ onTap: @escaping () -> Void
116
+ ) -> UIButton {
117
+ let button = UIButton(type: .system)
118
+ button.setTitle(text, for: .normal)
119
+ button.setTitleColor(textColor, for: .normal)
120
+ if let systemImage { button.setImage(UIImage(systemName: systemImage), for: .normal) }
121
+ button.tintColor = textColor
122
+ button.backgroundColor = fillColor
123
+ button.layer.cornerRadius = DesignTokens.radiusLg
124
+ button.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
125
+ button.contentEdgeInsets = UIEdgeInsets(top: 0, left: DesignTokens.spaceLg, bottom: 0, right: DesignTokens.spaceLg)
126
+ if #available(iOS 15.0, *) {
127
+ button.configuration = nil // avoid UIButtonConfiguration overriding our manual styling on iOS 15+
128
+ }
129
+ button.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
130
+ button.layer.shadowColor = fillColor.cgColor
131
+ button.layer.shadowOpacity = 0.35
132
+ button.layer.shadowRadius = 12
133
+ button.layer.shadowOffset = CGSize(width: 0, height: 4)
134
+ button.addAction(UIAction { _ in onTap() }, for: .touchUpInside)
135
+ button.applyPressScale()
136
+ return button
137
+ }
138
+ }
@@ -0,0 +1,13 @@
1
+ import Foundation
2
+
3
+ /// Resolves an editor source uri (file:// or a bare path) to a local
4
+ /// filesystem path that ImageIO can read directly. Photos-library identifiers
5
+ /// (ph://) and remote https:// sources are not yet supported.
6
+ enum SourceResolver {
7
+ static func resolvePath(sourceUri: String, tempPrefix: String) -> String? {
8
+ guard let url = URL(string: sourceUri) else { return nil }
9
+ if url.isFileURL { return url.path }
10
+ if url.scheme == nil { return sourceUri }
11
+ return nil
12
+ }
13
+ }
@@ -0,0 +1,3 @@
1
+ import Foundation
2
+
3
+ struct EditorRequest { let uri: String; let mediaType: String; let mimeType: String? }
@@ -0,0 +1,144 @@
1
+ import ImageIO
2
+ import UIKit
3
+
4
+ struct PhotoExportResult {
5
+ let uri: String
6
+ let width: Int
7
+ let height: Int
8
+ let fileSize: Int
9
+ let mimeType: String
10
+ }
11
+
12
+ struct PhotoExportError: Error {
13
+ let code: String
14
+ let message: String
15
+ }
16
+
17
+ /// Applies a `PhotoTransformState` to the full-resolution source photo and
18
+ /// writes the result to a new file under the app's temporary directory. The
19
+ /// source file is never modified. WebP export is not implemented on iOS
20
+ /// (no first-party encoder); JPEG and PNG are supported.
21
+ enum PhotoExporter {
22
+ private static let defaultMaxDimension: CGFloat = 4096
23
+
24
+ static func export(
25
+ sourceUri: String,
26
+ transform: PhotoTransformState,
27
+ adjustments: PhotoAdjustments,
28
+ layers: [PhotoLayer],
29
+ exportOptions: [String: Any]?
30
+ ) throws -> PhotoExportResult {
31
+ guard let path = SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_export") else {
32
+ throw PhotoExportError(code: "E_SOURCE_NOT_FOUND", message: "The selected photo could not be found.")
33
+ }
34
+ let url = URL(fileURLWithPath: path)
35
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
36
+ throw PhotoExportError(code: "E_SOURCE_UNREADABLE", message: "The selected photo could not be decoded.")
37
+ }
38
+
39
+ let maxWidth = (exportOptions?["maxWidth"] as? NSNumber)?.doubleValue.map { CGFloat($0) }
40
+ let maxHeight = (exportOptions?["maxHeight"] as? NSNumber)?.doubleValue.map { CGFloat($0) }
41
+ let decodeTarget = max(maxWidth ?? defaultMaxDimension, maxHeight ?? defaultMaxDimension) * 2
42
+
43
+ let thumbnailOptions: [CFString: Any] = [
44
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
45
+ kCGImageSourceThumbnailMaxPixelSize: decodeTarget,
46
+ kCGImageSourceCreateThumbnailWithTransform: true,
47
+ ]
48
+ guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
49
+ throw PhotoExportError(code: "E_OUT_OF_MEMORY", message: "The photo is too large to process on this device.")
50
+ }
51
+ let upright = UIImage(cgImage: cgImage)
52
+
53
+ let transformed = PhotoEditSession.applyTransform(upright, state: transform)
54
+ let adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments: adjustments)
55
+ let layered = PhotoLayerRenderer.render(adjusted, layers: layers) { uri in resolveImageLayer(uri) }
56
+ let cropped = crop(layered, transform: transform)
57
+ let resized = resize(cropped, maxWidth: maxWidth, maxHeight: maxHeight)
58
+
59
+ let format = (exportOptions?["imageFormat"] as? String)?.lowercased() ?? "jpeg"
60
+ let quality = qualityFor(exportOptions?["quality"] as? String ?? "high")
61
+
62
+ let outputDir = FileManager.default.temporaryDirectory.appendingPathComponent("photovideoeditor", isDirectory: true)
63
+ try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
64
+
65
+ let data: Data?
66
+ let mimeType: String
67
+ let ext: String
68
+ switch format {
69
+ case "png":
70
+ data = resized.pngData()
71
+ mimeType = "image/png"
72
+ ext = "png"
73
+ default:
74
+ // WebP encoding is not implemented on iOS; unsupported formats fall back to JPEG.
75
+ data = resized.jpegData(compressionQuality: quality)
76
+ mimeType = "image/jpeg"
77
+ ext = "jpg"
78
+ }
79
+ guard let outputData = data else {
80
+ throw PhotoExportError(code: "E_EXPORT_FAILED", message: "Unable to encode the exported photo.")
81
+ }
82
+
83
+ let outputFile = outputDir.appendingPathComponent(UUID().uuidString + "." + ext)
84
+ do {
85
+ try outputData.write(to: outputFile)
86
+ } catch {
87
+ throw PhotoExportError(code: "E_EXPORT_FAILED", message: "Unable to write the exported photo.")
88
+ }
89
+
90
+ let attributes = try? FileManager.default.attributesOfItem(atPath: outputFile.path)
91
+ let fileSize = (attributes?[.size] as? Int) ?? outputData.count
92
+
93
+ return PhotoExportResult(
94
+ uri: outputFile.absoluteString,
95
+ width: Int(resized.size.width),
96
+ height: Int(resized.size.height),
97
+ fileSize: fileSize,
98
+ mimeType: mimeType
99
+ )
100
+ }
101
+
102
+ /// Export-time, one-shot resolver for sticker-uri and overlay-uri layers alike (no cache needed —
103
+ /// export runs once per invocation).
104
+ private static func resolveImageLayer(_ uri: String) -> UIImage? {
105
+ guard let path = SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_layer_export") else { return nil }
106
+ return UIImage(contentsOfFile: path)
107
+ }
108
+
109
+ private static func crop(_ image: UIImage, transform: PhotoTransformState) -> UIImage {
110
+ guard transform.cropLeft > 0 || transform.cropTop > 0 || transform.cropRight < 1 || transform.cropBottom < 1 else { return image }
111
+ guard let cgImage = image.cgImage else { return image }
112
+ let width = CGFloat(cgImage.width)
113
+ let height = CGFloat(cgImage.height)
114
+ let fullRect = CGRect(x: 0, y: 0, width: width, height: height)
115
+ let rect = CGRect(
116
+ x: transform.cropLeft * width,
117
+ y: transform.cropTop * height,
118
+ width: (transform.cropRight - transform.cropLeft) * width,
119
+ height: (transform.cropBottom - transform.cropTop) * height
120
+ ).integral.intersection(fullRect)
121
+ guard rect.width > 0, rect.height > 0, let croppedImage = cgImage.cropping(to: rect) else { return image }
122
+ return UIImage(cgImage: croppedImage)
123
+ }
124
+
125
+ private static func resize(_ image: UIImage, maxWidth: CGFloat?, maxHeight: CGFloat?) -> UIImage {
126
+ guard maxWidth != nil || maxHeight != nil else { return image }
127
+ let widthLimit = maxWidth ?? image.size.width
128
+ let heightLimit = maxHeight ?? image.size.height
129
+ guard image.size.width > widthLimit || image.size.height > heightLimit else { return image }
130
+ let scale = min(widthLimit / image.size.width, heightLimit / image.size.height)
131
+ let targetSize = CGSize(width: max(1, image.size.width * scale), height: max(1, image.size.height * scale))
132
+ let renderer = PhotoEditSession.pixelRenderer(size: targetSize)
133
+ return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) }
134
+ }
135
+
136
+ private static func qualityFor(_ preset: String) -> CGFloat {
137
+ switch preset {
138
+ case "low": return 0.5
139
+ case "medium": return 0.75
140
+ case "original": return 1.0
141
+ default: return 0.9
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,22 @@
1
+ import CoreGraphics
2
+
3
+ /// Pure normalized-coordinate mapping used by preview and export rendering.
4
+ enum OverlayGeometry {
5
+ struct Transform {
6
+ let center: CGPoint
7
+ let scale: CGFloat
8
+ let rotationDegrees: CGFloat
9
+ let opacity: CGFloat
10
+ let shortSide: CGFloat
11
+ }
12
+
13
+ static func map(_ layer: PhotoLayer, frameSize: CGSize) -> Transform {
14
+ Transform(
15
+ center: CGPoint(x: layer.x * frameSize.width, y: layer.y * frameSize.height),
16
+ scale: layer.scale,
17
+ rotationDegrees: layer.rotationDegrees,
18
+ opacity: min(max(layer.opacity, 0), 1),
19
+ shortSide: min(frameSize.width, frameSize.height)
20
+ )
21
+ }
22
+ }
@@ -0,0 +1,141 @@
1
+ import UIKit
2
+
3
+ /// Applies the non-destructive `PhotoAdjustments` stack to a `UIImage`. The
4
+ /// live preview and the exporter both call this, in this exact order, so what
5
+ /// the user sees always matches what gets exported — and the per-channel
6
+ /// formulas mirror `PhotoAdjustmentRenderer.kt` so Android/iOS output stays
7
+ /// visually close:
8
+ ///
9
+ /// color adjustments (brightness/contrast/saturation/exposure/temperature)
10
+ /// -> blur -> mirror -> preset filter blend.
11
+ enum PhotoAdjustmentRenderer {
12
+ static func apply(_ source: UIImage, adjustments: PhotoAdjustments) -> UIImage {
13
+ guard !adjustments.isIdentity, var buffer = PixelBuffer(image: source) else { return source }
14
+
15
+ let needsColorPass = adjustments.brightness != 0 || adjustments.contrast != 0 || adjustments.saturation != 0 ||
16
+ adjustments.exposure != 0 || adjustments.temperature != 0
17
+ if needsColorPass {
18
+ applyColorAdjustments(&buffer, adjustments)
19
+ }
20
+ if adjustments.blurRadius >= 1 {
21
+ applyBoxBlur(&buffer, radius: Int(adjustments.blurRadius.rounded()))
22
+ }
23
+
24
+ var image = buffer.makeImage() ?? source
25
+ if adjustments.mirror {
26
+ image = applyMirror(image)
27
+ }
28
+ if let preset = adjustments.filterPreset {
29
+ image = applyPreset(image, preset: preset, strength: adjustments.filterStrength / 100)
30
+ }
31
+ return image
32
+ }
33
+
34
+ private static func applyColorAdjustments(_ buffer: inout PixelBuffer, _ adjustments: PhotoAdjustments) {
35
+ let exposureScale = pow(2, adjustments.exposure / 100)
36
+ let brightnessOffset = adjustments.brightness / 100 * 80
37
+ let contrastScale = (adjustments.contrast + 100) / 100
38
+ let contrastTranslate = 128 * (1 - contrastScale)
39
+ let saturationFactor = (adjustments.saturation + 100) / 100
40
+ let tempShift = adjustments.temperature / 100 * 40
41
+
42
+ for y in 0..<buffer.height {
43
+ for x in 0..<buffer.width {
44
+ let pixel = buffer[x, y]
45
+ var r = Double(pixel.r) * exposureScale + brightnessOffset
46
+ var g = Double(pixel.g) * exposureScale + brightnessOffset
47
+ var b = Double(pixel.b) * exposureScale + brightnessOffset
48
+ r = r * contrastScale + contrastTranslate
49
+ g = g * contrastScale + contrastTranslate
50
+ b = b * contrastScale + contrastTranslate
51
+ let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
52
+ r = luminance + (r - luminance) * saturationFactor
53
+ g = luminance + (g - luminance) * saturationFactor
54
+ b = luminance + (b - luminance) * saturationFactor
55
+ r += tempShift
56
+ b -= tempShift
57
+ buffer[x, y] = (clampByte(r), clampByte(g), clampByte(b), pixel.a)
58
+ }
59
+ }
60
+ }
61
+
62
+ /// Approximate Gaussian blur via three passes of separable box blur.
63
+ private static func applyBoxBlur(_ buffer: inout PixelBuffer, radius: Int) {
64
+ guard radius > 0 else { return }
65
+ for _ in 0..<3 {
66
+ boxBlurPass(&buffer, radius: radius, horizontal: true)
67
+ boxBlurPass(&buffer, radius: radius, horizontal: false)
68
+ }
69
+ }
70
+
71
+ private static func boxBlurPass(_ buffer: inout PixelBuffer, radius: Int, horizontal: Bool) {
72
+ let source = buffer
73
+ let outerLimit = horizontal ? buffer.height : buffer.width
74
+ let innerLimit = horizontal ? buffer.width : buffer.height
75
+ for outer in 0..<outerLimit {
76
+ for inner in 0..<innerLimit {
77
+ var r = 0
78
+ var g = 0
79
+ var b = 0
80
+ var a = 0
81
+ var count = 0
82
+ for offset in -radius...radius {
83
+ let sample = min(max(inner + offset, 0), innerLimit - 1)
84
+ let (x, y) = horizontal ? (sample, outer) : (outer, sample)
85
+ let pixel = source[x, y]
86
+ r += Int(pixel.r); g += Int(pixel.g); b += Int(pixel.b); a += Int(pixel.a)
87
+ count += 1
88
+ }
89
+ let (ox, oy) = horizontal ? (inner, outer) : (outer, inner)
90
+ buffer[ox, oy] = (UInt8(r / count), UInt8(g / count), UInt8(b / count), UInt8(a / count))
91
+ }
92
+ }
93
+ }
94
+
95
+ /// Mirrors the left half of the image onto the right half.
96
+ private static func applyMirror(_ image: UIImage) -> UIImage {
97
+ guard image.size.width >= 2, let cgImage = image.cgImage else { return image }
98
+ let halfWidth = CGFloat(Int(image.size.width) / 2)
99
+ let leftRect = CGRect(x: 0, y: 0, width: halfWidth, height: image.size.height)
100
+ guard let leftCG = cgImage.cropping(to: leftRect) else { return image }
101
+ let leftImage = UIImage(cgImage: leftCG)
102
+ let renderer = PhotoEditSession.pixelRenderer(size: image.size)
103
+ return renderer.image { context in
104
+ leftImage.draw(in: leftRect)
105
+ context.cgContext.saveGState()
106
+ context.cgContext.translateBy(x: image.size.width, y: 0)
107
+ context.cgContext.scaleBy(x: -1, y: 1)
108
+ leftImage.draw(in: leftRect)
109
+ context.cgContext.restoreGState()
110
+ }
111
+ }
112
+
113
+ private static func applyPreset(_ image: UIImage, preset: String, strength: Double) -> UIImage {
114
+ guard let recipe = PhotoFilterPresets.recipe(for: preset), var buffer = PixelBuffer(image: image) else { return image }
115
+ for y in 0..<buffer.height {
116
+ for x in 0..<buffer.width {
117
+ let pixel = buffer[x, y]
118
+ let luminance = 0.2126 * Double(pixel.r) + 0.7152 * Double(pixel.g) + 0.0722 * Double(pixel.b)
119
+ var r = luminance + (Double(pixel.r) - luminance) * recipe.saturation
120
+ var g = luminance + (Double(pixel.g) - luminance) * recipe.saturation
121
+ var b = luminance + (Double(pixel.b) - luminance) * recipe.saturation
122
+ r = r * recipe.scale + recipe.redOffset
123
+ g = g * recipe.scale + recipe.greenOffset
124
+ b = b * recipe.scale + recipe.blueOffset
125
+ buffer[x, y] = (clampByte(r), clampByte(g), clampByte(b), pixel.a)
126
+ }
127
+ }
128
+ guard let filtered = buffer.makeImage() else { return image }
129
+ guard strength < 1 else { return filtered }
130
+
131
+ let renderer = PhotoEditSession.pixelRenderer(size: image.size)
132
+ return renderer.image { _ in
133
+ image.draw(in: CGRect(origin: .zero, size: image.size))
134
+ filtered.draw(in: CGRect(origin: .zero, size: image.size), blendMode: .normal, alpha: CGFloat(strength))
135
+ }
136
+ }
137
+
138
+ private static func clampByte(_ value: Double) -> UInt8 {
139
+ UInt8(min(max(value.rounded(), 0), 255))
140
+ }
141
+ }
@@ -0,0 +1,57 @@
1
+ import Foundation
2
+
3
+ /// Non-destructive photo adjustment stack (Milestone 3). Every numeric field
4
+ /// has a documented range and a zero/no-op default so "reset" is always just
5
+ /// `PhotoAdjustments()`. Mirrors `PhotoAdjustments.kt` field-for-field so the
6
+ /// two platforms can share the same formulas in their renderers.
7
+ struct PhotoAdjustments: Equatable {
8
+ var brightness: Double = 0 // -100..100
9
+ var contrast: Double = 0 // -100..100
10
+ var saturation: Double = 0 // -100..100
11
+ var exposure: Double = 0 // -100..100
12
+ var temperature: Double = 0 // -100..100
13
+ var blurRadius: Double = 0 // 0..25 (px)
14
+ var mirror: Bool = false
15
+ var filterPreset: String?
16
+ var filterStrength: Double = 100 // 0..100, only meaningful when filterPreset is set
17
+
18
+ var isIdentity: Bool { self == PhotoAdjustments() }
19
+
20
+ func value(_ key: String) -> Double {
21
+ switch key {
22
+ case "brightness": return brightness
23
+ case "contrast": return contrast
24
+ case "saturation": return saturation
25
+ case "exposure": return exposure
26
+ case "temperature": return temperature
27
+ case "blurRadius": return blurRadius
28
+ case "filterStrength": return filterStrength
29
+ default: return 0
30
+ }
31
+ }
32
+
33
+ mutating func setValue(_ key: String, _ newValue: Double) {
34
+ switch key {
35
+ case "brightness": brightness = newValue
36
+ case "contrast": contrast = newValue
37
+ case "saturation": saturation = newValue
38
+ case "exposure": exposure = newValue
39
+ case "temperature": temperature = newValue
40
+ case "blurRadius": blurRadius = newValue
41
+ case "filterStrength": filterStrength = newValue
42
+ default: break
43
+ }
44
+ }
45
+
46
+ static let sliderKeys = [
47
+ "brightness", "contrast", "saturation", "exposure", "temperature", "blurRadius",
48
+ ]
49
+
50
+ static func range(for key: String) -> (Double, Double) {
51
+ switch key {
52
+ case "blurRadius": return (0, 25)
53
+ case "filterStrength": return (0, 100)
54
+ default: return (-100, 100)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,121 @@
1
+ import ImageIO
2
+ import UIKit
3
+
4
+ /// Owns an in-memory, downsampled working copy of the source photo for fast,
5
+ /// memory-safe live preview. The full-resolution source is decoded again,
6
+ /// once, only at export time by `PhotoExporter`.
7
+ final class PhotoEditSession {
8
+ private(set) var state = PhotoTransformState()
9
+ private(set) var adjustments = PhotoAdjustments()
10
+ let layerStack = PhotoLayerStack()
11
+ let baseImage: UIImage?
12
+
13
+ private var cachedBaseImage: UIImage?
14
+ private var cachedState: PhotoTransformState?
15
+ private var cachedAdjustments: PhotoAdjustments?
16
+ private var cachedPreviewImage: UIImage?
17
+ private weak var cachedPreviewBaseImage: UIImage?
18
+ private var cachedPreviewLayerRevision: UInt64?
19
+ /// Resolved images for both sticker-uri and overlay-uri layers — the lookup logic is identical
20
+ /// for either layer type, so one cache/resolver serves both.
21
+ private var imageLayerCache: [String: UIImage?] = [:]
22
+
23
+ init(sourceUri: String, maxPreviewDimension: CGFloat = 1600) {
24
+ baseImage = PhotoEditSession.decodeUpright(sourceUri: sourceUri, maxDimension: maxPreviewDimension)
25
+ }
26
+
27
+ func update(_ transform: (inout PhotoTransformState) -> Void) {
28
+ transform(&state)
29
+ }
30
+
31
+ func updateAdjustments(_ transform: (inout PhotoAdjustments) -> Void) {
32
+ transform(&adjustments)
33
+ }
34
+
35
+ /// Rotate/flip/straighten + adjustments only, cached so dragging a layer doesn't re-run the full pipeline every frame.
36
+ private func computeBase() -> UIImage? {
37
+ guard let base = baseImage else { return nil }
38
+ if let cached = cachedBaseImage, cachedState == state, cachedAdjustments == adjustments {
39
+ return cached
40
+ }
41
+ let transformed: UIImage
42
+ if state.rotationDegrees == 0 && state.straightenDegrees == 0 {
43
+ transformed = base
44
+ } else {
45
+ transformed = PhotoEditSession.applyTransform(base, state: state)
46
+ }
47
+ let adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments: adjustments)
48
+ cachedBaseImage = adjusted
49
+ cachedState = state
50
+ cachedAdjustments = adjustments
51
+ return adjusted
52
+ }
53
+
54
+ /// Renders rotate/flip/straighten + adjustments + layers — the crop overlay draws the crop live on top of this.
55
+ func renderPreview() -> UIImage? {
56
+ guard let base = computeBase() else { return nil }
57
+ if cachedPreviewBaseImage === base,
58
+ cachedPreviewLayerRevision == layerStack.revision,
59
+ let cachedPreviewImage {
60
+ return cachedPreviewImage
61
+ }
62
+ let rendered = PhotoLayerRenderer.render(base, layers: layerStack.layers) { [weak self] uri in self?.resolveImageLayer(uri) }
63
+ cachedPreviewBaseImage = base
64
+ cachedPreviewLayerRevision = layerStack.revision
65
+ cachedPreviewImage = rendered
66
+ return rendered
67
+ }
68
+
69
+ func release() {
70
+ cachedBaseImage = nil
71
+ cachedState = nil
72
+ cachedAdjustments = nil
73
+ cachedPreviewImage = nil
74
+ cachedPreviewBaseImage = nil
75
+ cachedPreviewLayerRevision = nil
76
+ imageLayerCache.removeAll()
77
+ }
78
+
79
+ private func resolveImageLayer(_ uri: String) -> UIImage? {
80
+ if let cached = imageLayerCache[uri] { return cached }
81
+ let path = SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_layer_image")
82
+ let image = path.flatMap { UIImage(contentsOfFile: $0) }
83
+ imageLayerCache[uri] = image
84
+ return image
85
+ }
86
+
87
+ /// A renderer whose output pixel size exactly matches its point size, so
88
+ /// width/height math stays consistent with `CGImage.width`/`height` elsewhere.
89
+ static func pixelRenderer(size: CGSize) -> UIGraphicsImageRenderer {
90
+ let format = UIGraphicsImageRendererFormat()
91
+ format.scale = 1
92
+ return UIGraphicsImageRenderer(size: size, format: format)
93
+ }
94
+
95
+ static func applyTransform(_ image: UIImage, state: PhotoTransformState) -> UIImage {
96
+ guard state.rotationDegrees != 0 || state.straightenDegrees != 0 else { return image }
97
+ let radians = state.totalRotationRadians
98
+ let rotatedBounds = CGRect(origin: .zero, size: image.size).applying(CGAffineTransform(rotationAngle: radians))
99
+ let canvasSize = CGSize(width: abs(rotatedBounds.width), height: abs(rotatedBounds.height))
100
+ let renderer = pixelRenderer(size: canvasSize)
101
+ return renderer.image { context in
102
+ let ctx = context.cgContext
103
+ ctx.translateBy(x: canvasSize.width / 2, y: canvasSize.height / 2)
104
+ ctx.rotate(by: radians)
105
+ image.draw(in: CGRect(x: -image.size.width / 2, y: -image.size.height / 2, width: image.size.width, height: image.size.height))
106
+ }
107
+ }
108
+
109
+ private static func decodeUpright(sourceUri: String, maxDimension: CGFloat) -> UIImage? {
110
+ guard let path = SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_preview") else { return nil }
111
+ let url = URL(fileURLWithPath: path)
112
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
113
+ let options: [CFString: Any] = [
114
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
115
+ kCGImageSourceThumbnailMaxPixelSize: maxDimension,
116
+ kCGImageSourceCreateThumbnailWithTransform: true,
117
+ ]
118
+ guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
119
+ return UIImage(cgImage: cgImage)
120
+ }
121
+ }