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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PhotoVideoEditor.podspec +1 -0
  3. package/README.md +10 -6
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/AndroidManifest.xml +1 -1
  7. package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
  8. package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
  9. package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
  10. package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
  11. package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
  12. package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
  13. package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
  14. package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
  15. package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
  16. package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
  17. package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
  18. package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
  19. package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
  20. package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
  21. package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
  22. package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
  23. package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
  24. package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
  25. package/android/src/main/assets/Stickers/LOL_1.png +0 -0
  26. package/android/src/main/assets/Stickers/LOL_10.png +0 -0
  27. package/android/src/main/assets/Stickers/LOL_11.png +0 -0
  28. package/android/src/main/assets/Stickers/LOL_12.png +0 -0
  29. package/android/src/main/assets/Stickers/LOL_13.png +0 -0
  30. package/android/src/main/assets/Stickers/LOL_14.png +0 -0
  31. package/android/src/main/assets/Stickers/LOL_15.png +0 -0
  32. package/android/src/main/assets/Stickers/LOL_16.png +0 -0
  33. package/android/src/main/assets/Stickers/LOL_17.png +0 -0
  34. package/android/src/main/assets/Stickers/LOL_18.png +0 -0
  35. package/android/src/main/assets/Stickers/LOL_19.png +0 -0
  36. package/android/src/main/assets/Stickers/LOL_2.png +0 -0
  37. package/android/src/main/assets/Stickers/LOL_3.png +0 -0
  38. package/android/src/main/assets/Stickers/LOL_4.png +0 -0
  39. package/android/src/main/assets/Stickers/LOL_5.png +0 -0
  40. package/android/src/main/assets/Stickers/LOL_6.png +0 -0
  41. package/android/src/main/assets/Stickers/LOL_7.png +0 -0
  42. package/android/src/main/assets/Stickers/LOL_8.png +0 -0
  43. package/android/src/main/assets/Stickers/LOL_9.png +0 -0
  44. package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
  45. package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
  46. package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
  47. package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
  48. package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
  49. package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
  50. package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
  51. package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
  52. package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
  53. package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
  54. package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
  55. package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
  56. package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
  57. package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
  58. package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
  59. package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
  60. package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
  61. package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
  62. package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
  63. package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
  64. package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
  65. package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
  66. package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
  67. package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
  68. package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
  69. package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
  70. package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
  71. package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
  72. package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
  73. package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
  74. package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
  75. package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
  76. package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
  77. package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
  78. package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
  79. package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
  80. package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
  81. package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
  82. package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
  83. package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
  84. package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
  85. package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
  86. package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
  87. package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
  88. package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
  89. package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
  90. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +901 -651
  91. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  92. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  93. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  94. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  95. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  96. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
  97. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +24 -1
  98. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  99. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  100. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +112 -122
  101. package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
  102. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +63 -14
  103. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  104. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
  105. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  106. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  107. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  108. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  109. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  110. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  111. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  112. package/android/src/main/res/drawable/ic_draw.xml +6 -0
  113. package/android/src/main/res/drawable/ic_pause.xml +7 -0
  114. package/android/src/main/res/drawable/ic_play.xml +7 -0
  115. package/docs/api.md +1 -1
  116. package/docs/editor-ui-validation.md +22 -0
  117. package/docs/photo-editor.md +5 -6
  118. package/ios/Editor/PhotoVideoEditorViewController.swift +918 -494
  119. package/ios/Editor/UI/CropPanel.swift +97 -0
  120. package/ios/Editor/UI/EditorPanel.swift +334 -0
  121. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  122. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  123. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  124. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  125. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  126. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  127. package/ios/Photo/Rendering/PhotoLayer.swift +14 -2
  128. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +16 -1
  129. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  130. package/ios/Photo/UI/CropOverlayView.swift +98 -134
  131. package/ios/Photo/UI/DrawOverlayView.swift +48 -0
  132. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  133. package/ios/Photo/UI/LayerOverlayView.swift +36 -11
  134. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  135. package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
  136. package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
  137. package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
  138. package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
  139. package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
  140. package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
  141. package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
  142. package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
  143. package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
  144. package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
  145. package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
  146. package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
  147. package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
  148. package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
  149. package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
  150. package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
  151. package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
  152. package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
  153. package/ios/Resources/Stickers/LOL_1.png +0 -0
  154. package/ios/Resources/Stickers/LOL_10.png +0 -0
  155. package/ios/Resources/Stickers/LOL_11.png +0 -0
  156. package/ios/Resources/Stickers/LOL_12.png +0 -0
  157. package/ios/Resources/Stickers/LOL_13.png +0 -0
  158. package/ios/Resources/Stickers/LOL_14.png +0 -0
  159. package/ios/Resources/Stickers/LOL_15.png +0 -0
  160. package/ios/Resources/Stickers/LOL_16.png +0 -0
  161. package/ios/Resources/Stickers/LOL_17.png +0 -0
  162. package/ios/Resources/Stickers/LOL_18.png +0 -0
  163. package/ios/Resources/Stickers/LOL_19.png +0 -0
  164. package/ios/Resources/Stickers/LOL_2.png +0 -0
  165. package/ios/Resources/Stickers/LOL_3.png +0 -0
  166. package/ios/Resources/Stickers/LOL_4.png +0 -0
  167. package/ios/Resources/Stickers/LOL_5.png +0 -0
  168. package/ios/Resources/Stickers/LOL_6.png +0 -0
  169. package/ios/Resources/Stickers/LOL_7.png +0 -0
  170. package/ios/Resources/Stickers/LOL_8.png +0 -0
  171. package/ios/Resources/Stickers/LOL_9.png +0 -0
  172. package/ios/Resources/Stickers/SPRING_1.png +0 -0
  173. package/ios/Resources/Stickers/SPRING_2.png +0 -0
  174. package/ios/Resources/Stickers/SPRING_3.png +0 -0
  175. package/ios/Resources/Stickers/SPRING_4.png +0 -0
  176. package/ios/Resources/Stickers/SPRING_5.png +0 -0
  177. package/ios/Resources/Stickers/SPRING_6.png +0 -0
  178. package/ios/Resources/Stickers/SUMMER_1.png +0 -0
  179. package/ios/Resources/Stickers/SUMMER_10.png +0 -0
  180. package/ios/Resources/Stickers/SUMMER_11.png +0 -0
  181. package/ios/Resources/Stickers/SUMMER_12.png +0 -0
  182. package/ios/Resources/Stickers/SUMMER_13.png +0 -0
  183. package/ios/Resources/Stickers/SUMMER_14.png +0 -0
  184. package/ios/Resources/Stickers/SUMMER_15.png +0 -0
  185. package/ios/Resources/Stickers/SUMMER_16.png +0 -0
  186. package/ios/Resources/Stickers/SUMMER_17.png +0 -0
  187. package/ios/Resources/Stickers/SUMMER_18.png +0 -0
  188. package/ios/Resources/Stickers/SUMMER_19.png +0 -0
  189. package/ios/Resources/Stickers/SUMMER_2.png +0 -0
  190. package/ios/Resources/Stickers/SUMMER_20.png +0 -0
  191. package/ios/Resources/Stickers/SUMMER_21.png +0 -0
  192. package/ios/Resources/Stickers/SUMMER_3.png +0 -0
  193. package/ios/Resources/Stickers/SUMMER_4.png +0 -0
  194. package/ios/Resources/Stickers/SUMMER_5.png +0 -0
  195. package/ios/Resources/Stickers/SUMMER_6.png +0 -0
  196. package/ios/Resources/Stickers/SUMMER_7.png +0 -0
  197. package/ios/Resources/Stickers/SUMMER_8.png +0 -0
  198. package/ios/Resources/Stickers/SUMMER_9.png +0 -0
  199. package/ios/Resources/Stickers/TEXT_1.png +0 -0
  200. package/ios/Resources/Stickers/TEXT_12.png +0 -0
  201. package/ios/Resources/Stickers/TEXT_13.png +0 -0
  202. package/ios/Resources/Stickers/TEXT_14.png +0 -0
  203. package/ios/Resources/Stickers/TEXT_15.png +0 -0
  204. package/ios/Resources/Stickers/TEXT_16.png +0 -0
  205. package/ios/Resources/Stickers/TEXT_2.png +0 -0
  206. package/ios/Resources/Stickers/TEXT_25.png +0 -0
  207. package/ios/Resources/Stickers/TEXT_26.png +0 -0
  208. package/ios/Resources/Stickers/TEXT_27.png +0 -0
  209. package/ios/Resources/Stickers/TEXT_28.png +0 -0
  210. package/ios/Resources/Stickers/TEXT_29.png +0 -0
  211. package/ios/Resources/Stickers/TEXT_3.png +0 -0
  212. package/ios/Resources/Stickers/TEXT_30.png +0 -0
  213. package/ios/Resources/Stickers/TEXT_31.png +0 -0
  214. package/ios/Resources/Stickers/TEXT_32.png +0 -0
  215. package/ios/Resources/Stickers/TEXT_7.png +0 -0
  216. package/ios/Resources/Stickers/TEXT_8.png +0 -0
  217. package/ios/Resources/Stickers/TEXT_9.png +0 -0
  218. package/ios/Resources/Stickers/a_pety_icon.png +0 -0
  219. package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
  220. package/ios/Video/Export/VideoExporter.swift +10 -1
  221. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  222. package/ios/Video/Render/VideoTransformState.swift +1 -0
  223. package/lib/module/index.js +2 -2
  224. package/lib/module/index.js.map +1 -1
  225. package/lib/typescript/src/types.d.ts +2 -2
  226. package/lib/typescript/src/types.d.ts.map +1 -1
  227. package/package.json +2 -1
  228. package/src/index.ts +1 -1
  229. package/src/types.ts +2 -2
  230. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
  231. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
  232. package/ios/Stickers/OpenMojiCatalog.swift +0 -188
  233. package/ios/Stickers/RemoteImageLoader.swift +0 -127
@@ -1,9 +1,11 @@
1
1
  import Foundation
2
2
 
3
- /// A small set of original, neutrally named preset filters (no third-party
4
- /// looks or names copied). Each recipe is applied by the renderer as:
5
- /// 1) set saturation, 2) scale RGB, 3) add the per-channel offset —
6
- /// mirroring the matrices in `PhotoFilterPresets.kt`.
3
+ /// Original, neutrally named preset filters (no third-party looks or names copied).
4
+ ///
5
+ /// Every preset is expressed as the same four knobs the renderer already supports, so a preset can
6
+ /// never be a button that does nothing: 1) set saturation, 2) scale RGB, 3) add the per-channel
7
+ /// offset. `PhotoFilterPresets.kt` mirrors this table field-for-field — add presets to both or
8
+ /// neither, or the two platforms will render different looks for the same id.
7
9
  struct PhotoFilterRecipe {
8
10
  let scale: Double
9
11
  let redOffset: Double
@@ -13,33 +15,61 @@ struct PhotoFilterRecipe {
13
15
  }
14
16
 
15
17
  enum PhotoFilterPresets {
16
- static let presetIDs = ["mono", "noir", "fade", "chrome", "warmth"]
18
+ /// Display order in the carousel. "Original" (no preset) is prepended by the UI, not listed here.
19
+ static let presetIDs = [
20
+ "vivid", "vividWarm", "vividCool",
21
+ "warmth", "cool",
22
+ "natural", "soft", "fade",
23
+ "mono", "noir", "silvertone",
24
+ "vintage", "sepia", "retro",
25
+ "dramatic", "dramaticWarm", "dramaticCool",
26
+ ]
27
+
28
+ private static let recipes: [String: PhotoFilterRecipe] = [
29
+ "vivid": PhotoFilterRecipe(scale: 1.05, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.45),
30
+ "vividWarm": PhotoFilterRecipe(scale: 1.05, redOffset: 12, greenOffset: 4, blueOffset: -10, saturation: 1.45),
31
+ "vividCool": PhotoFilterRecipe(scale: 1.05, redOffset: -8, greenOffset: 0, blueOffset: 14, saturation: 1.45),
32
+ "warmth": PhotoFilterRecipe(scale: 1, redOffset: 18, greenOffset: 6, blueOffset: -12, saturation: 1.05),
33
+ "cool": PhotoFilterRecipe(scale: 1, redOffset: -12, greenOffset: 0, blueOffset: 18, saturation: 1.05),
34
+ "natural": PhotoFilterRecipe(scale: 1.02, redOffset: 2, greenOffset: 2, blueOffset: 0, saturation: 1.15),
35
+ "soft": PhotoFilterRecipe(scale: 0.96, redOffset: 14, greenOffset: 12, blueOffset: 12, saturation: 0.9),
36
+ "fade": PhotoFilterRecipe(scale: 0.9, redOffset: 25, greenOffset: 25, blueOffset: 25, saturation: 1),
37
+ "mono": PhotoFilterRecipe(scale: 1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 0),
38
+ "noir": PhotoFilterRecipe(scale: 1.3, redOffset: -40, greenOffset: -40, blueOffset: -40, saturation: 0),
39
+ "silvertone": PhotoFilterRecipe(scale: 1.1, redOffset: -6, greenOffset: -2, blueOffset: 10, saturation: 0),
40
+ "vintage": PhotoFilterRecipe(scale: 0.95, redOffset: 24, greenOffset: 12, blueOffset: -6, saturation: 0.6),
41
+ "sepia": PhotoFilterRecipe(scale: 1, redOffset: 38, greenOffset: 20, blueOffset: -14, saturation: 0),
42
+ "retro": PhotoFilterRecipe(scale: 0.92, redOffset: 20, greenOffset: 6, blueOffset: 14, saturation: 0.75),
43
+ "dramatic": PhotoFilterRecipe(scale: 1.25, redOffset: -28, greenOffset: -28, blueOffset: -28, saturation: 1.2),
44
+ "dramaticWarm": PhotoFilterRecipe(scale: 1.25, redOffset: -14, greenOffset: -26, blueOffset: -38, saturation: 1.2),
45
+ "dramaticCool": PhotoFilterRecipe(scale: 1.25, redOffset: -38, greenOffset: -28, blueOffset: -12, saturation: 1.2),
46
+ // Retained so photos saved with an earlier preset id still render; not offered in the carousel.
47
+ "chrome": PhotoFilterRecipe(scale: 1.1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.4),
48
+ ]
17
49
 
18
50
  static func label(for id: String) -> String {
19
51
  switch id {
52
+ case "vivid": return "Vivid"
53
+ case "vividWarm": return "Vivid Warm"
54
+ case "vividCool": return "Vivid Cool"
55
+ case "warmth": return "Warm"
56
+ case "cool": return "Cool"
57
+ case "natural": return "Natural"
58
+ case "soft": return "Soft"
59
+ case "fade": return "Fade"
20
60
  case "mono": return "Mono"
21
61
  case "noir": return "Noir"
22
- case "fade": return "Fade"
62
+ case "silvertone": return "Silvertone"
63
+ case "vintage": return "Vintage"
64
+ case "sepia": return "Sepia"
65
+ case "retro": return "Retro"
66
+ case "dramatic": return "Dramatic"
67
+ case "dramaticWarm": return "Dramatic Warm"
68
+ case "dramaticCool": return "Dramatic Cool"
23
69
  case "chrome": return "Chrome"
24
- case "warmth": return "Warmth"
25
70
  default: return id
26
71
  }
27
72
  }
28
73
 
29
- static func recipe(for id: String) -> PhotoFilterRecipe? {
30
- switch id {
31
- case "mono":
32
- return PhotoFilterRecipe(scale: 1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 0)
33
- case "noir":
34
- return PhotoFilterRecipe(scale: 1.3, redOffset: -40, greenOffset: -40, blueOffset: -40, saturation: 0)
35
- case "fade":
36
- return PhotoFilterRecipe(scale: 0.9, redOffset: 25, greenOffset: 25, blueOffset: 25, saturation: 1)
37
- case "chrome":
38
- return PhotoFilterRecipe(scale: 1.1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.4)
39
- case "warmth":
40
- return PhotoFilterRecipe(scale: 1, redOffset: 18, greenOffset: 6, blueOffset: -12, saturation: 1)
41
- default:
42
- return nil
43
- }
44
- }
74
+ static func recipe(for id: String) -> PhotoFilterRecipe? { recipes[id] }
45
75
  }
@@ -1,7 +1,7 @@
1
1
  import UIKit
2
2
 
3
3
  enum LayerType {
4
- case text, sticker, overlay
4
+ case text, sticker, overlay, drawing
5
5
  }
6
6
 
7
7
  /// A single non-destructive overlay layer (text, sticker, or a user-uploaded
@@ -11,7 +11,7 @@ enum LayerType {
11
11
  ///
12
12
  /// `x`/`y` are the layer's center, normalized 0..1 against the image.
13
13
  struct PhotoLayer {
14
- let id: String
14
+ var id: String
15
15
  var type: LayerType
16
16
  var x: CGFloat = 0.5
17
17
  var y: CGFloat = 0.5
@@ -31,6 +31,10 @@ struct PhotoLayer {
31
31
  // Overlay (user-uploaded image, placed as a fully generic movable/resizable/rotatable layer)
32
32
  var overlayUri: String?
33
33
  var overlayAspectRatio: CGFloat?
34
+ // Drawing (points are normalized offsets from x/y)
35
+ var drawColor: UIColor = .red
36
+ var drawStrokeWidth: CGFloat = 0.012
37
+ var drawPoints: [(CGFloat, CGFloat)] = []
34
38
  // Timing (video overlays only; ignored for photo layers). endMs <= 0 means "to end of video".
35
39
  var startMs: Int64 = 0
36
40
  var endMs: Int64 = 0
@@ -40,6 +44,14 @@ struct PhotoLayer {
40
44
  self.type = type
41
45
  }
42
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
+
43
55
  /// True if this layer should be visible at `positionMs` against a video of `durationMs`. Always true for photo layers.
44
56
  func effectiveEndMs(durationMs: Int64) -> Int64 {
45
57
  (endMs > 0 && endMs <= durationMs) ? endMs : durationMs
@@ -29,6 +29,7 @@ enum PhotoLayerRenderer {
29
29
  case .text: drawText(layer, shortSide: geometry.shortSide)
30
30
  case .sticker: drawSticker(layer, shortSide: geometry.shortSide, resolver: imageResolver)
31
31
  case .overlay: drawOverlay(layer, canvasWidth: base.size.width, resolver: imageResolver)
32
+ case .drawing: drawStroke(ctx, layer, width: base.size.width, height: base.size.height, shortSide: geometry.shortSide)
32
33
  }
33
34
  ctx.restoreGState()
34
35
  }
@@ -53,8 +54,9 @@ enum PhotoLayerRenderer {
53
54
 
54
55
  private static func drawSticker(_ layer: PhotoLayer, shortSide: CGFloat, resolver: (String) -> UIImage?) {
55
56
  let size = shortSide * 0.18
56
- let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
57
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)
58
60
  image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
59
61
  return
60
62
  }
@@ -80,6 +82,19 @@ enum PhotoLayerRenderer {
80
82
  image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
81
83
  }
82
84
 
85
+ private static func drawStroke(_ ctx: CGContext, _ layer: PhotoLayer, width: CGFloat, height: CGFloat, shortSide: CGFloat) {
86
+ guard layer.drawPoints.count >= 2 else { return }
87
+ ctx.setAlpha(layer.opacity)
88
+ ctx.setStrokeColor(layer.drawColor.cgColor)
89
+ ctx.setLineWidth(layer.drawStrokeWidth * shortSide)
90
+ ctx.setLineCap(.round)
91
+ ctx.setLineJoin(.round)
92
+ let points = layer.drawPoints.map { CGPoint(x: $0.0 * width, y: $0.1 * height) }
93
+ ctx.move(to: points[0])
94
+ points.dropFirst().forEach { ctx.addLine(to: $0) }
95
+ ctx.strokePath()
96
+ }
97
+
83
98
  }
84
99
 
85
100
  private extension NSAttributedString {
@@ -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,161 +1,125 @@
1
1
  import UIKit
2
2
 
3
- /// Draws a draggable/resizable crop rectangle over `imageBounds` (the
4
- /// on-screen rect of the preview image, in this view's own coordinate space)
5
- /// and reports the crop edges back normalized to that rect via `onCropChanged`.
3
+ /// Handle gestures resize the box; interior gestures pan/pinch the media, keeping the box fixed.
6
4
  final class CropOverlayView: UIView {
7
- var onCropChanged: ((_ left: CGFloat, _ top: CGFloat, _ right: CGFloat, _ bottom: CGFloat) -> Void)?
8
-
9
- private var imageBounds: CGRect = .zero
10
- private var cropRect: CGRect = .zero
5
+ var onCropChanged: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)?
6
+ var onMediaBoundsChanged: ((CGRect) -> Void)?
7
+ var onViewportChanged: ((CGFloat, CGFloat, CGFloat) -> Void)?
8
+ private var imageBounds = CGRect.zero, fittedBounds = CGRect.zero, cropRect = CGRect.zero
11
9
  private var aspectRatio: CGFloat?
12
-
13
- private let dimLayer = CAShapeLayer()
14
- private let borderLayer = CAShapeLayer()
15
- private let 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
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
21
14
 
22
15
  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)
16
+ super.init(frame: frame); backgroundColor = .clear; isOpaque = false; isMultipleTouchEnabled = true
32
17
  }
33
-
34
18
  required init?(coder: NSCoder) { nil }
35
19
 
36
- /// Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when `resetCrop` is true.
20
+ func restore(bounds: CGRect, state: PhotoTransformState) {
21
+ guard bounds.width > 0, bounds.height > 0 else { return }
22
+ fittedBounds = bounds
23
+ let zoom = max(1, min(8, state.zoom))
24
+ let cx = bounds.midX + state.panX*bounds.width, cy = bounds.midY + state.panY*bounds.height
25
+ imageBounds = CGRect(x: cx-bounds.width*zoom/2, y: cy-bounds.height*zoom/2, width: bounds.width*zoom, height: bounds.height*zoom)
26
+ aspectRatio = state.aspectRatio
27
+ setCrop(left: state.cropLeft, top: state.cropTop, right: state.cropRight, bottom: state.cropBottom)
28
+ onMediaBoundsChanged?(imageBounds)
29
+ }
37
30
  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()
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()
36
+ }
37
+ func setCrop(left: CGFloat, top: CGFloat, right: CGFloat, bottom: CGFloat) {
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()
44
41
  }
45
-
46
42
  func setAspectRatio(_ ratio: CGFloat?) {
47
43
  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()
44
+ cropRect = CropGeometry.fit(imageBounds.intersection(bounds), ratio: ratio)
45
+ reportCrop(); setNeedsDisplay()
61
46
  }
62
47
 
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))
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))
58
+ }
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
+ }
80
65
  }
81
- borderLayer.path = borderPath.cgPath
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()
82
69
  }
83
70
 
84
71
  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
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
88
76
  }
89
-
90
77
  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
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)
106
84
  }
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
85
+ lastPoint = point; lastDistance = distance; reportCrop(); setNeedsDisplay()
113
86
  }
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()
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 }
144
92
  }
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 }
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)
151
105
  }
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)
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)
160
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)) }
161
125
  }
@@ -0,0 +1,48 @@
1
+ import UIKit
2
+
3
+ struct DrawStroke {
4
+ var points: [CGPoint]
5
+ let color: UIColor
6
+ let widthPx: CGFloat
7
+ }
8
+
9
+ /** Captures freehand strokes over the visible image while Draw mode is active. */
10
+ final class DrawOverlayView: UIView {
11
+ private var imageBounds: CGRect = .zero
12
+ var strokeColor: UIColor = .red
13
+ var strokeWidthPx: CGFloat = 12
14
+ private var strokes: [DrawStroke] = []
15
+ private var currentIndex: Int?
16
+
17
+ override init(frame: CGRect) { super.init(frame: frame); backgroundColor = .clear; isOpaque = false }
18
+ required init?(coder: NSCoder) { nil }
19
+ func setImageBounds(_ bounds: CGRect) { imageBounds = bounds }
20
+ func clearStrokes() { strokes.removeAll(); currentIndex = nil; setNeedsDisplay() }
21
+ func undoLastStroke() { if !strokes.isEmpty { strokes.removeLast() }; setNeedsDisplay() }
22
+ var hasStrokes: Bool { !strokes.isEmpty }
23
+ func normalizedStrokes() -> [(points: [(CGFloat, CGFloat)], stroke: DrawStroke)] {
24
+ guard imageBounds.width > 0, imageBounds.height > 0 else { return [] }
25
+ return strokes.map { stroke in
26
+ (stroke.points.map { (($0.x - imageBounds.minX) / imageBounds.width, ($0.y - imageBounds.minY) / imageBounds.height) }, stroke)
27
+ }
28
+ }
29
+
30
+ override func draw(_ rect: CGRect) {
31
+ guard let ctx = UIGraphicsGetCurrentContext() else { return }
32
+ ctx.setLineCap(.round); ctx.setLineJoin(.round)
33
+ for stroke in strokes where stroke.points.count >= 2 {
34
+ ctx.setStrokeColor(stroke.color.cgColor); ctx.setLineWidth(stroke.widthPx)
35
+ ctx.move(to: stroke.points[0]); stroke.points.dropFirst().forEach { ctx.addLine(to: $0) }; ctx.strokePath()
36
+ }
37
+ }
38
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
39
+ guard let point = touches.first?.location(in: self), imageBounds.contains(point) else { return }
40
+ strokes.append(DrawStroke(points: [point], color: strokeColor, widthPx: strokeWidthPx)); currentIndex = strokes.count - 1; setNeedsDisplay()
41
+ }
42
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
43
+ guard let index = currentIndex, let point = touches.first?.location(in: self) else { return }
44
+ strokes[index].points.append(point); setNeedsDisplay()
45
+ }
46
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { currentIndex = nil }
47
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { currentIndex = nil }
48
+ }
@@ -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
+ }