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
@@ -0,0 +1,250 @@
1
+ package com.photovideoeditor.ui.components
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.graphics.Typeface
6
+ import android.view.Gravity
7
+ import android.view.View
8
+ import android.widget.FrameLayout
9
+ import android.widget.HorizontalScrollView
10
+ import android.widget.ImageView
11
+ import android.widget.LinearLayout
12
+ import android.widget.TextView
13
+ import com.photovideoeditor.ui.DesignTokens
14
+
15
+ /**
16
+ * Shared building blocks for the editor's bottom panels (Crop / Adjust / Filters / Text / Stickers).
17
+ *
18
+ * Before this existed each panel hand-rolled its own header, chips and spacing, which is how the
19
+ * Adjust panel ended up with four full-height rectangular buttons while Filters used thumbnail cards.
20
+ * Every panel now composes the same three pieces — [editorPanelHeader], one slider, one
21
+ * [editorToolStrip] — so heights, padding, radii, typography and selected states stay in step.
22
+ *
23
+ * Fixed vertical budget (see PANEL_* below): header 40dp + slider ~48dp + strip 76dp, so a panel
24
+ * never grows past ~164dp and the photo keeps the rest of the screen.
25
+ */
26
+ object EditorPanelMetrics {
27
+ const val HEADER_HEIGHT_DP = 40
28
+ const val SLIDER_HEIGHT_DP = 48
29
+ const val STRIP_HEIGHT_DP = 76
30
+ const val STRIP_ITEM_WIDTH_DP = 68
31
+ const val ICON_SIZE_DP = 22
32
+ const val THUMBNAIL_WIDTH_DP = 64
33
+ const val THUMBNAIL_HEIGHT_DP = 72
34
+ const val TOTAL_HEIGHT_DP = HEADER_HEIGHT_DP + SLIDER_HEIGHT_DP + STRIP_HEIGHT_DP
35
+ /** Panel without the slider row (e.g. a picker that has nothing to scrub). */
36
+ const val HEADER_AND_STRIP_HEIGHT_DP = HEADER_HEIGHT_DP + STRIP_HEIGHT_DP
37
+ }
38
+
39
+ /**
40
+ * Compact panel header: `Title [Reset] Done`.
41
+ *
42
+ * "Done" is an action, not a tool — it gets a 44dp touch target via padding while staying visually
43
+ * small, rather than becoming another full-width card.
44
+ */
45
+ fun editorPanelHeader(
46
+ context: Context,
47
+ title: String,
48
+ accentColor: Int,
49
+ secondaryLabel: String? = null,
50
+ onSecondary: (() -> Unit)? = null,
51
+ onDone: () -> Unit
52
+ ): LinearLayout = LinearLayout(context).apply {
53
+ orientation = LinearLayout.HORIZONTAL
54
+ gravity = Gravity.CENTER_VERTICAL
55
+ setPadding(dpToPx(DesignTokens.spaceLg, context), 0, dpToPx(DesignTokens.spaceSm, context), 0)
56
+ addView(TextView(context).apply {
57
+ text = title
58
+ textSize = 14f
59
+ maxLines = 1
60
+ includeFontPadding = false
61
+ setTypeface(typeface, Typeface.BOLD)
62
+ setTextColor(DesignTokens.textPrimary)
63
+ }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
64
+ if (secondaryLabel != null && onSecondary != null) {
65
+ addView(editorHeaderAction(context, secondaryLabel, DesignTokens.textSecondary, onSecondary))
66
+ }
67
+ addView(editorHeaderAction(context, "Done", accentColor, onDone))
68
+ }
69
+
70
+ /** Small text action sized for touch (44dp) without looking like a button. */
71
+ private fun editorHeaderAction(context: Context, label: String, color: Int, onClick: () -> Unit): TextView =
72
+ TextView(context).apply {
73
+ text = label
74
+ textSize = 13f
75
+ maxLines = 1
76
+ gravity = Gravity.CENTER
77
+ includeFontPadding = false
78
+ setTypeface(typeface, Typeface.BOLD)
79
+ setTextColor(color)
80
+ minWidth = dpToPx(DesignTokens.touchTargetMin, context)
81
+ minHeight = dpToPx(DesignTokens.touchTargetMin, context)
82
+ val padH = dpToPx(DesignTokens.spaceMd, context)
83
+ setPadding(padH, 0, padH, 0)
84
+ isClickable = true
85
+ isFocusable = true
86
+ setOnClickListener { onClick() }
87
+ applyPressScale()
88
+ }
89
+
90
+ /** Horizontally scrollable strip of compact tool items; the container every panel's list sits in. */
91
+ fun editorToolStrip(context: Context, build: LinearLayout.() -> Unit): HorizontalScrollView =
92
+ HorizontalScrollView(context).apply {
93
+ isHorizontalScrollBarEnabled = false
94
+ clipToPadding = false
95
+ val padH = dpToPx(DesignTokens.spaceMd, context)
96
+ setPadding(padH, 0, padH, 0)
97
+ addView(LinearLayout(context).apply {
98
+ orientation = LinearLayout.HORIZONTAL
99
+ gravity = Gravity.CENTER_VERTICAL
100
+ build()
101
+ }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT))
102
+ }
103
+
104
+ /** An icon + single-line label entry in a strip, with an optional "value changed" dot. */
105
+ class EditorStripItem(
106
+ val root: LinearLayout,
107
+ private val iconHolder: FrameLayout,
108
+ private val icon: ImageView,
109
+ private val label: TextView,
110
+ private val dot: View
111
+ ) {
112
+ /**
113
+ * Selected state is a purple icon + label over a faint purple disc — never a filled purple tile,
114
+ * which at this size reads as a giant button rather than a selection.
115
+ */
116
+ fun setSelected(selected: Boolean, accentColor: Int) {
117
+ icon.setColorFilter(if (selected) accentColor else DesignTokens.textSecondary)
118
+ label.setTextColor(if (selected) accentColor else DesignTokens.textSecondary)
119
+ iconHolder.background = if (selected) {
120
+ circleDrawable(DesignTokens.withAlphaPercent(accentColor, 18))
121
+ } else {
122
+ null
123
+ }
124
+ root.isSelected = selected
125
+ }
126
+
127
+ /** Tiny dot marking an adjustment that is no longer at its default value. */
128
+ fun setModified(modified: Boolean, accentColor: Int) {
129
+ dot.visibility = if (modified) View.VISIBLE else View.INVISIBLE
130
+ dot.background = circleDrawable(accentColor)
131
+ }
132
+ }
133
+
134
+ private fun circleDrawable(color: Int): android.graphics.drawable.GradientDrawable =
135
+ android.graphics.drawable.GradientDrawable().apply {
136
+ shape = android.graphics.drawable.GradientDrawable.OVAL
137
+ setColor(color)
138
+ }
139
+
140
+ fun editorStripItem(
141
+ context: Context,
142
+ iconRes: Int,
143
+ labelText: String,
144
+ onClick: () -> Unit
145
+ ): EditorStripItem {
146
+ val icon = ImageView(context).apply {
147
+ setImageResource(iconRes)
148
+ setColorFilter(DesignTokens.textSecondary)
149
+ }
150
+ val iconHolder = FrameLayout(context).apply {
151
+ addView(
152
+ icon,
153
+ FrameLayout.LayoutParams(
154
+ dpToPx(EditorPanelMetrics.ICON_SIZE_DP, context),
155
+ dpToPx(EditorPanelMetrics.ICON_SIZE_DP, context),
156
+ Gravity.CENTER
157
+ )
158
+ )
159
+ }
160
+ val label = TextView(context).apply {
161
+ text = labelText
162
+ textSize = 11f
163
+ // Labels like "Brightness" and "Saturation" must never wrap to a second line.
164
+ maxLines = 1
165
+ includeFontPadding = false
166
+ gravity = Gravity.CENTER
167
+ setTextColor(DesignTokens.textSecondary)
168
+ setPadding(0, dpToPx(DesignTokens.spaceXs, context), 0, 0)
169
+ }
170
+ val dot = View(context).apply { visibility = View.INVISIBLE }
171
+ val root = LinearLayout(context).apply {
172
+ orientation = LinearLayout.VERTICAL
173
+ gravity = Gravity.CENTER_HORIZONTAL
174
+ isClickable = true
175
+ isFocusable = true
176
+ contentDescription = labelText
177
+ minimumHeight = dpToPx(DesignTokens.touchTargetMin, context)
178
+ addView(
179
+ iconHolder,
180
+ LinearLayout.LayoutParams(dpToPx(32, context), dpToPx(32, context))
181
+ )
182
+ addView(label, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
183
+ addView(dot, LinearLayout.LayoutParams(dpToPx(4, context), dpToPx(4, context)).apply {
184
+ topMargin = dpToPx(3, context)
185
+ })
186
+ setOnClickListener { onClick() }
187
+ applyPressScale()
188
+ }
189
+ return EditorStripItem(root, iconHolder, icon, label, dot)
190
+ }
191
+
192
+ /**
193
+ * Filter thumbnail: a preview of the current photo, outlined in purple when selected.
194
+ * Deliberately a thin outline rather than a filled container, so the photo stays readable.
195
+ */
196
+ class EditorFilterThumbnail(val root: LinearLayout, val image: ImageView, private val frame: FrameLayout, private val label: TextView) {
197
+ fun setSelected(selected: Boolean, accentColor: Int) {
198
+ frame.background = roundedDrawable(
199
+ color = Color.TRANSPARENT,
200
+ radiusDp = DesignTokens.radiusMd,
201
+ context = root.context,
202
+ strokeColor = if (selected) accentColor else Color.TRANSPARENT,
203
+ strokeWidthDp = 2
204
+ )
205
+ label.setTextColor(if (selected) accentColor else DesignTokens.textSecondary)
206
+ root.isSelected = selected
207
+ }
208
+ }
209
+
210
+ fun editorFilterThumbnail(context: Context, labelText: String, onClick: () -> Unit): EditorFilterThumbnail {
211
+ val image = ImageView(context).apply {
212
+ scaleType = ImageView.ScaleType.CENTER_CROP
213
+ background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusMd, context)
214
+ clipToOutline = true
215
+ outlineProvider = android.view.ViewOutlineProvider.BACKGROUND
216
+ }
217
+ // The outline lives on a frame *around* the image so the stroke never crops the preview.
218
+ val frame = FrameLayout(context).apply {
219
+ val inset = dpToPx(2, context)
220
+ setPadding(inset, inset, inset, inset)
221
+ addView(
222
+ image,
223
+ FrameLayout.LayoutParams(
224
+ dpToPx(EditorPanelMetrics.THUMBNAIL_WIDTH_DP, context),
225
+ dpToPx(EditorPanelMetrics.THUMBNAIL_HEIGHT_DP, context)
226
+ )
227
+ )
228
+ }
229
+ val label = TextView(context).apply {
230
+ text = labelText
231
+ textSize = 11f
232
+ maxLines = 1
233
+ includeFontPadding = false
234
+ gravity = Gravity.CENTER
235
+ setTextColor(DesignTokens.textSecondary)
236
+ setPadding(0, dpToPx(DesignTokens.spaceXs, context), 0, 0)
237
+ }
238
+ val root = LinearLayout(context).apply {
239
+ orientation = LinearLayout.VERTICAL
240
+ gravity = Gravity.CENTER_HORIZONTAL
241
+ isClickable = true
242
+ isFocusable = true
243
+ contentDescription = labelText
244
+ addView(frame, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT))
245
+ addView(label, LinearLayout.LayoutParams(dpToPx(EditorPanelMetrics.THUMBNAIL_WIDTH_DP + 8, context), LinearLayout.LayoutParams.WRAP_CONTENT))
246
+ setOnClickListener { onClick() }
247
+ applyPressScale()
248
+ }
249
+ return EditorFilterThumbnail(root, image, frame, label)
250
+ }
@@ -0,0 +1,103 @@
1
+ package com.photovideoeditor.ui.components
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.drawable.ClipDrawable
8
+ import android.graphics.drawable.GradientDrawable
9
+ import android.graphics.drawable.LayerDrawable
10
+ import android.view.Gravity
11
+ import android.widget.SeekBar
12
+ import com.photovideoeditor.ui.DesignTokens
13
+
14
+ /**
15
+ * The editor's one slider. Draws its own `Label ................ value` line above a thin track, so
16
+ * a panel never needs a separate caption row, and the same instance can be re-pointed at whichever
17
+ * adjustment/property is selected instead of every property owning its own slider.
18
+ */
19
+ class EditorToolSlider(context: Context) : SeekBar(context) {
20
+ var propertyKey: String = ""
21
+ set(value) { field = value; contentDescription = labelText ?: value; invalidate() }
22
+
23
+ /** Overrides the [propertyKey]-derived caption (e.g. "Brightness", "Intensity"). */
24
+ var labelText: String? = null
25
+ set(value) { field = value; contentDescription = value ?: propertyKey; invalidate() }
26
+
27
+ /** Overrides the value readout, given the raw progress. */
28
+ var valueFormatter: ((Int) -> String)? = null
29
+ set(value) { field = value; invalidate() }
30
+
31
+ /**
32
+ * Progress at which the underlying value is neutral/default, for bipolar ranges. When set, a tick
33
+ * is drawn there so it is obvious where "no change" sits.
34
+ */
35
+ var neutralProgress: Int? = null
36
+ set(value) { field = value; invalidate() }
37
+
38
+ private val density = resources.displayMetrics.density
39
+ private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
40
+ color = DesignTokens.textSecondary
41
+ textSize = 12 * density
42
+ }
43
+ private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = DesignTokens.textSubtle }
44
+
45
+ init {
46
+ minimumHeight = (48 * density).toInt()
47
+ setPadding((16 * density).toInt(), (20 * density).toInt(), (16 * density).toInt(), 0)
48
+ }
49
+
50
+ /** Thin track, accent-coloured progress, compact thumb — the slider styling used editor-wide. */
51
+ fun applyCompactTrack(accentColor: Int) {
52
+ val inset = (9 * density).toInt()
53
+ progressDrawable = LayerDrawable(
54
+ arrayOf(
55
+ pill(Color.argb(70, 255, 255, 255)),
56
+ ClipDrawable(pill(accentColor), Gravity.START, ClipDrawable.HORIZONTAL)
57
+ )
58
+ ).apply {
59
+ setId(0, android.R.id.background)
60
+ setId(1, android.R.id.progress)
61
+ setLayerInset(0, 0, inset, 0, inset)
62
+ setLayerInset(1, 0, inset, 0, inset)
63
+ }
64
+ thumb = GradientDrawable().apply {
65
+ shape = GradientDrawable.OVAL
66
+ setColor(Color.WHITE)
67
+ setSize((12 * density).toInt(), (12 * density).toInt())
68
+ }
69
+ splitTrack = false
70
+ }
71
+
72
+ private fun pill(color: Int) = GradientDrawable().apply {
73
+ cornerRadius = 999f
74
+ setColor(color)
75
+ }
76
+
77
+ override fun onDraw(canvas: Canvas) {
78
+ neutralProgress?.let { neutral ->
79
+ if (max > 0) {
80
+ val trackWidth = width - paddingLeft - paddingRight
81
+ val x = paddingLeft + trackWidth * (neutral.toFloat() / max)
82
+ val centerY = (height + paddingTop) / 2f
83
+ canvas.drawRect(x - density, centerY - 5 * density, x + density, centerY + 5 * density, tickPaint)
84
+ }
85
+ }
86
+ super.onDraw(canvas)
87
+ val label = labelText ?: when (propertyKey) {
88
+ "fontSize" -> "Size"
89
+ "rotation" -> "Rotate"
90
+ else -> propertyKey.replaceFirstChar { it.uppercase() }
91
+ }
92
+ val value = valueFormatter?.invoke(progress) ?: when (propertyKey) {
93
+ "rotation" -> "${progress - 180}°"
94
+ "fontSize" -> "${progress + 12}"
95
+ "opacity" -> "$progress%"
96
+ else -> "${progress + 20}%"
97
+ }
98
+ labelPaint.textAlign = Paint.Align.LEFT
99
+ canvas.drawText(label, paddingLeft.toFloat(), 14 * density, labelPaint)
100
+ labelPaint.textAlign = Paint.Align.RIGHT
101
+ canvas.drawText(value, (width - paddingRight).toFloat(), 14 * density, labelPaint)
102
+ }
103
+ }
@@ -10,6 +10,7 @@ import android.os.Looper
10
10
  import androidx.media3.common.Effect
11
11
  import androidx.media3.common.MediaItem
12
12
  import androidx.media3.common.MimeTypes
13
+ import androidx.media3.common.OverlaySettings
13
14
  import androidx.media3.common.util.Size
14
15
  import androidx.media3.effect.BitmapOverlay
15
16
  import androidx.media3.effect.Brightness
@@ -18,6 +19,7 @@ import androidx.media3.effect.HslAdjustment
18
19
  import androidx.media3.effect.OverlayEffect
19
20
  import androidx.media3.effect.Presentation
20
21
  import androidx.media3.effect.ScaleAndRotateTransformation
22
+ import androidx.media3.effect.StaticOverlaySettings
21
23
  import androidx.media3.effect.TextureOverlay
22
24
  import androidx.media3.transformer.Composition
23
25
  import androidx.media3.transformer.EditedMediaItem
@@ -28,6 +30,7 @@ import androidx.media3.transformer.ExportResult
28
30
  import androidx.media3.transformer.ProgressHolder
29
31
  import androidx.media3.transformer.Transformer
30
32
  import com.photovideoeditor.files.SourceResolver
33
+ import com.photovideoeditor.photo.render.OverlayGeometry
31
34
  import com.photovideoeditor.photo.render.PhotoLayer
32
35
  import com.photovideoeditor.photo.render.PhotoLayerRenderer
33
36
  import com.photovideoeditor.video.render.VideoClip
@@ -120,6 +123,8 @@ class VideoExporter(private val context: Context) {
120
123
  effects.add(Presentation.createForAspectRatio(ratio, Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP))
121
124
  }
122
125
 
126
+ effects.addAll(com.photovideoeditor.video.render.VideoCropEffects.create(state.crop))
127
+
123
128
  // Video-level filters: export-only for now, no live preview — see docs/video-editor.md.
124
129
  if (state.brightness != 0f) effects.add(Brightness(state.brightness / 100f))
125
130
  if (state.contrast != 0f) effects.add(Contrast(state.contrast / 100f))
@@ -148,20 +153,37 @@ class VideoExporter(private val context: Context) {
148
153
  object : BitmapOverlay() {
149
154
  @Volatile private var outputWidth = sourceWidth.coerceAtLeast(2)
150
155
  @Volatile private var outputHeight = sourceHeight.coerceAtLeast(2)
156
+ @Volatile private var settings: OverlaySettings = StaticOverlaySettings.Builder().build()
151
157
 
152
158
  override fun configure(videoSize: Size) {
153
159
  super.configure(videoSize)
154
- // Media3 maps a same-aspect overlay texture onto the complete background
155
- // frame. Bound the longest edge to avoid allocating a huge ARGB bitmap for
156
- // extreme (4K/8K) outputs, while preserving exact normalized geometry. The
157
- // cap is set to cover common Full HD exports (1920px long edge) natively —
158
- // a lower cap visibly softens overlay text/sticker edges vs. the preview,
159
- // since it downscales the overlay before compositing.
160
- val scale = minOf(1f, MAX_OVERLAY_EDGE_PX / maxOf(videoSize.width, videoSize.height).coerceAtLeast(1))
161
- outputWidth = (videoSize.width * scale).toInt().coerceAtLeast(2)
162
- outputHeight = (videoSize.height * scale).toInt().coerceAtLeast(2)
160
+ // Media3 composites an overlay texture **1:1 in pixels, centred** on the frame — it
161
+ // does NOT stretch it to fill (that is what iOS's `contentsGravity = .resizeAspect`
162
+ // does, which is why only Android was affected). Verified against the 1.8.0 bytecode
163
+ // of `OverlayMatrixProvider.getTransformationMatrix`: with default anchors/rotation
164
+ // its matrix chain collapses to
165
+ // net scale = (overlaySize / frameSize) * OverlaySettings.scale
166
+ // (the inner scale/scaleInv pair only exists to apply the anchor offset in unscaled
167
+ // space; the chain multiplies by `scaleMatrix` once more at the end).
168
+ //
169
+ // `PhotoLayerRenderer` draws in coordinates normalized to the bitmap it is handed,
170
+ // so an overlay bitmap smaller than the frame made every layer render *smaller and
171
+ // pulled toward the frame centre* by exactly the shortfall — e.g. a 4K export
172
+ // (3840px long edge) downscaled to a 1920px overlay put every sticker/text at half
173
+ // its preview size. Videos at or under the cap were unaffected, which is why this
174
+ // only reproduced on high-resolution sources.
175
+ //
176
+ // Fix: keep the memory cap, but tell Media3 to scale the overlay back up to the
177
+ // full frame. The compensation divides by the *rounded* bitmap size actually
178
+ // allocated (not the pre-rounding ratio) so rounding cannot reintroduce drift.
179
+ val plan = OverlayGeometry.canvasPlan(videoSize.width, videoSize.height, MAX_OVERLAY_EDGE_PX)
180
+ outputWidth = plan.width
181
+ outputHeight = plan.height
182
+ settings = StaticOverlaySettings.Builder().setScale(plan.scaleX, plan.scaleY).build()
163
183
  }
164
184
 
185
+ override fun getOverlaySettings(presentationTimeUs: Long): OverlaySettings = settings
186
+
165
187
  override fun getBitmap(presentationTimeUs: Long): Bitmap {
166
188
  val positionMs = presentationTimeUs / 1000 + clipOffsetMs
167
189
  val activeLayers = layers.filter { it.isActiveAt(positionMs, totalDurationMs) }
@@ -313,7 +335,12 @@ class VideoExporter(private val context: Context) {
313
335
  }
314
336
 
315
337
  private companion object {
316
- /** Overlay-compositing bitmap longest-edge cap; see the comment at its call site. */
317
- const val MAX_OVERLAY_EDGE_PX = 1920f
338
+ /**
339
+ * Overlay-compositing bitmap longest-edge cap — purely a memory guard (an ARGB_8888 bitmap this
340
+ * size is allocated twice transiently while rendering). Geometry no longer depends on it: any
341
+ * shortfall is scaled back to the full frame via `OverlaySettings` (see the `configure` comment),
342
+ * so the cap only trades overlay sharpness for peak memory on above-1440p exports.
343
+ */
344
+ const val MAX_OVERLAY_EDGE_PX = 2560f
318
345
  }
319
346
  }
@@ -0,0 +1,35 @@
1
+ package com.photovideoeditor.video.render
2
+
3
+ import android.graphics.Matrix
4
+ import androidx.media3.common.Effect
5
+ import androidx.media3.common.util.Size
6
+ import androidx.media3.effect.Crop
7
+ import androidx.media3.effect.MatrixTransformation
8
+ import androidx.media3.effect.ScaleAndRotateTransformation
9
+ import com.photovideoeditor.photo.render.CropGeometry
10
+ import com.photovideoeditor.photo.render.PhotoTransformState
11
+ import kotlin.math.cos
12
+ import kotlin.math.sin
13
+
14
+ /** Identical effect order for ExoPlayer preview and Transformer export. */
15
+ object VideoCropEffects {
16
+ fun create(state: PhotoTransformState, includeCrop: Boolean = true): List<Effect> = buildList {
17
+ if (state.rotationDegrees != 0) add(ScaleAndRotateTransformation.Builder().setRotationDegrees(-state.rotationDegrees.toFloat()).build())
18
+ if (state.straightenDegrees != 0f) add(object : MatrixTransformation {
19
+ private val matrix = Matrix()
20
+ override fun configure(inputWidth: Int, inputHeight: Int): Size {
21
+ val w = inputWidth.toFloat(); val h = inputHeight.toFloat()
22
+ val scale = CropGeometry.fillScale(w, h, state.straightenDegrees)
23
+ val radians = Math.toRadians(state.straightenDegrees.toDouble())
24
+ val c = cos(radians).toFloat() * scale; val s = sin(radians).toFloat() * scale
25
+ // GL's Y axis points up; UI rotation is clockwise with Y pointing down.
26
+ matrix.setValues(floatArrayOf(c, s * h / w, 0f, -s * w / h, c, 0f, 0f, 0f, 1f))
27
+ return Size(inputWidth, inputHeight)
28
+ }
29
+ override fun getMatrix(presentationTimeUs: Long) = matrix
30
+ })
31
+ if (includeCrop && (state.cropLeft != 0f || state.cropTop != 0f || state.cropRight != 1f || state.cropBottom != 1f)) {
32
+ add(Crop(state.cropLeft * 2 - 1, state.cropRight * 2 - 1, 1 - state.cropBottom * 2, 1 - state.cropTop * 2))
33
+ }
34
+ }
35
+ }
@@ -17,6 +17,10 @@ import java.io.File
17
17
  * clip list again to produce the exported file.
18
18
  */
19
19
  class VideoEditSession(context: Context, val sourceUri: String) {
20
+ var sourceWidth = 0
21
+ private set
22
+ var sourceHeight = 0
23
+ private set
20
24
  val player: ExoPlayer = ExoPlayer.Builder(context).build()
21
25
 
22
26
  /** Text/sticker/shape overlays. Reuses the photo layer model/stack — see `PhotoLayer.startMs`/`endMs` for timing. */
@@ -49,6 +53,12 @@ class VideoEditSession(context: Context, val sourceUri: String) {
49
53
 
50
54
  init {
51
55
  player.addListener(object : Player.Listener {
56
+ override fun onVideoSizeChanged(size: androidx.media3.common.VideoSize) {
57
+ if (sourceWidth == 0 && size.width > 0 && size.height > 0) {
58
+ sourceWidth = (size.width * size.pixelWidthHeightRatio).toInt()
59
+ sourceHeight = size.height
60
+ }
61
+ }
52
62
  override fun onPlaybackStateChanged(playbackState: Int) {
53
63
  if (playbackState == Player.STATE_READY && !initialTimelineCreated && player.duration > 0) {
54
64
  initialTimelineCreated = true
@@ -9,6 +9,7 @@ package com.photovideoeditor.video.render
9
9
  */
10
10
  data class VideoTransformState(
11
11
  val rotationDegrees: Int = 0,
12
+ val crop: com.photovideoeditor.photo.render.PhotoTransformState = com.photovideoeditor.photo.render.PhotoTransformState(),
12
13
  val aspectRatio: Float? = null,
13
14
  val coverFrameMs: Long = 0L,
14
15
  /** Preview-only for now: cycled via the Speed tool. Export does not yet honor this — see docs/video-editor.md. */
@@ -0,0 +1,6 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
3
+ <group android:translateY="960">
4
+ <path android:fillColor="#FFFFFF" android:pathData="M160-120v-170l527-526q12-12 27-18t30-6q16 0 30.5 6t25.5 18l56 56q12 11 18 25.5t6 30.5q0 15-6 30t-18 27L330-120H160Zm80-80h56l393-392-28-29-29-28-392 393v56Zm320 80q74 0 137-37t63-103q0-36-19-62t-51-45l-59 59q23 10 36 22t13 26q0 23-36.5 41.5T560-200q-17 0-28.5 11.5T520-160q0 17 11.5 28.5T560-120ZM183-426l60-60q-20-8-31.5-16.5T200-520q0-12 18-24t76-37q88-38 117-69t29-70q0-55-44-87.5T280-840q-45 0-80.5 16T145-785q-11 13-9 29t15 26q13 11 29 9t27-13q14-14 31-20t42-6q41 0 60.5 12t19.5 28q0 14-17.5 25.5T262-654q-80 35-111 63.5T120-520q0 32 17 54.5t46 39.5Z"/>
5
+ </group>
6
+ </vector>
@@ -0,0 +1,7 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path android:fillColor="#FFFFFFFF" android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z" />
7
+ </vector>
@@ -0,0 +1,7 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
7
+ </vector>
package/docs/api.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `openEditor(options)` validates defaults, invokes native code, and returns `Promise<EditorResult>`. In milestone one it returns the source URI/type and `cancelled: false`; it does not present UI or export. `cancelExport(jobId?)` currently returns `false`. `isAvailable()` synchronously checks native linkage. `addExportProgressListener(listener)` returns `{ remove() }`; no fake events are emitted.
4
4
 
5
- Defaults enable all feature flags, set quality to `high`, preserve metadata, and disable gallery saving. For photos, `trim` normalizes to `false`. Photo/video format mismatches and unknown enum values are rejected.
5
+ Defaults enable all feature flags—including `text`, `stickers`, `overlays`, and `draw`—set quality to `high`, preserve metadata, and disable gallery saving. For photos, `trim` normalizes to `false`. Photo/video format mismatches and unknown enum values are rejected.
6
6
 
7
7
  Dimensions use pixels, duration milliseconds, file size bytes, bitrate bits/second, frame rate frames/second, and progress ranges from 0 to 1. Public types and option-level JSDoc live in `src/types.ts`.
8
8
 
@@ -0,0 +1,22 @@
1
+ # Native editor UI
2
+
3
+ The Kotlin and UIKit editors reuse their existing layer stacks, normalized media coordinates, renderers, and export paths. Tool selection is transient UI state; opening a palette or slider does not mutate a layer. Slider gestures commit one history snapshot, and duplication copies all layer properties including timing and drawing data.
4
+
5
+ Both editors use contextual icon rails, an explicit Color palette for text, and custom multiline text entry. Unsupported font-family and alignment controls are omitted. Existing crop, adjustment, filtering, drawing, and layer actions remain available.
6
+
7
+ New stickers start at 30% of rendered media width. Their original image aspect ratio is retained using the existing `overlayAspectRatio` field. Existing layer scales retain their meaning. Bitmap stickers now render at their source aspect ratio instead of stretching into squares. Selection controls belong to a separate view and are not part of the bitmap/video compositor.
8
+
9
+ ## Device validation
10
+
11
+ The following needs device/simulator verification, particularly on iOS where UIKit cannot be built on Linux:
12
+
13
+ - Open portrait, landscape, and square photos/videos; check complete media visibility and centered placement.
14
+ - Add multiline text with the keyboard open in portrait and landscape. Check Add, Edit, Cancel, focus, and keyboard dismissal.
15
+ - Change text color, size, rotation, and opacity; switch tools; check one-step undo/redo and retained position/timing.
16
+ - Add a non-square sticker, drag, pinch, rotate, release one finger, duplicate, and delete through the canvas handle.
17
+ - Tap outside to dismiss selection; check the main toolbar returns without extra panels.
18
+ - Seek/play/pause video; verify overlay visibility at its start/end times.
19
+ - Export at original resolution and compare overlay centers, dimensions, colors, opacity, rotation, and ordering with the preview. Selection chrome must be absent.
20
+ - Check small screens, large text settings, navigation insets, VoiceOver/TalkBack, and horizontal toolbar scrolling.
21
+
22
+ Android unit coverage includes portrait/landscape/square sticker sizing, preview/export proportional geometry, layer-edit state preservation, and undo/redo. These tests do not replace rendered export comparison or keyboard/gesture checks on devices.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Milestone 2 — transform and export
4
4
 
5
- - **Crop** — draggable/resizable freeform crop with corner handles, plus aspect-ratio presets (Free, 1:1, 4:5, 3:4, 9:16, 16:9). Tap **Crop** to enter crop mode; **Done** in the crop sub-bar commits it.
5
+ - **Crop** — dedicated ZLImageEditor-inspired crop mode with a dimmed mask, rule-of-thirds grid, four corner and four edge handles, movable crop frame, and aspect presets (Free, 1:1, 4:5, 3:4, 9:16, 16:9). **Cancel** restores the exact pre-crop state; **Done** commits the crop.
6
6
  - **Rotate** — rotates 90° per tap. Resets the current crop (a rotated image has a different frame of reference for the crop rectangle).
7
7
  - **Flip** — cycles none → horizontal → vertical → none.
8
8
  - **Straighten** — a fine -45°...45° slider, shown while crop mode is active.
@@ -18,13 +18,13 @@ Tap **Filters** to enter adjustment mode: a horizontal chip row selects which co
18
18
  - **Reset** (filters sub-bar) — restores all adjustments to their zero/no-op defaults, independent of crop/transform/layers.
19
19
  - Same pipeline and formulas applied in preview and export on both platforms.
20
20
 
21
- ## Milestone 4 — layers: text, stickers, shapes, drawing
21
+ ## Milestone 4 — layers: text, stickers, images, drawing
22
22
 
23
23
  - **Text** — tap **Text**, type a string in the dialog, and it's added as a centered, draggable text layer.
24
- - **Stickers** — tap **Stickers** for a picker with 8 built-in emoji stickers, any consumer-provided `stickerAssets` (see `EditorOptions.stickerAssets`), and three basic shapes (Rect/Oval/Line) sharing the same picker.
24
+ - **Stickers** — tap **Stickers** for the searchable, categorized sticker collection bundled with the package. No third-party sticker API or network connection is used.
25
25
  - **Draw** — tap **Draw** for a freehand canvas: pick a color and thin/thick width, draw multiple strokes, undo the last stroke or clear all, then **Done** commits every stroke as its own layer (so strokes with different colors/widths keep their own settings).
26
26
  - **Selection** — tap any layer on the canvas to select it (shown with a dashed bounding box) and open its mini toolbar.
27
- - **Transform** — the mini toolbar's Scale/Rotate/Opacity chips each bind a shared slider; dragging the layer directly on the canvas moves it. (Two-finger pinch/rotate gestures were scoped out in favor of sliders, to keep the gesture code small enough to be confident in without a device to test on — see Known limitations in the root README.)
27
+ - **Transform** — drag layers to move them, pinch with two fingers to scale and rotate, or use the corner handle. Scale/Rotate/Opacity sliders remain available for precise changes.
28
28
  - **Duplicate / Lock / Hide / Front / Back / Delete** — per-layer actions in the mini toolbar.
29
29
  - **Undo/redo** — header buttons, backed by a snapshot history (50 entries). One entry is recorded per *committed* operation — add/delete/duplicate/reorder, or a drag/slider gesture's final value — never per intermediate touch-move frame.
30
30
  - Layers are burned into the image identically in preview and at export, positioned in the same full-frame coordinate space as the crop overlay (so cropping after adding layers can crop them out, matching what the crop preview would suggest).
@@ -32,8 +32,7 @@ Tap **Filters** to enter adjustment mode: a horizontal chip row selects which co
32
32
  ### Known gaps in Milestone 4
33
33
 
34
34
  - **Font registration is not wired to custom font files yet.** `EditorOptions.fonts` is accepted structurally (passed through to the native layer) but text layers currently only resolve `fontFamily` against system-installed font names (Android `Typeface.create`, iOS `UIFont(name:)`) — a consumer-provided font *file* is not loaded. This is an honest scope cut, not a silent gap: closing it means adding a font-file loader (`Typeface.createFromFile` / `CTFontManagerRegisterFontsForURL`) on each platform, planned as a small follow-up.
35
- - Hit-testing for layer selection uses an axis-aligned box around each layer's center and does not account for the layer's own rotation.
36
- - No sealed per-type layer model — one flat `PhotoLayer` struct/data class covers text/sticker/shape/drawing, a deliberate simplification.
35
+ - No sealed per-type layer model — one flat `PhotoLayer` struct/data class covers text/sticker/image/drawing, a deliberate simplification.
37
36
  - Text styling is limited to color and font size for now (no stroke/background/shadow/alignment controls yet, though the fields exist on `PhotoLayer` for a later pass).
38
37
 
39
38
  Export applies the combined transform + adjustments + layers to the full-resolution source (never mutated) and writes JPEG or PNG (WebP on Android only; iOS falls back to JPEG). Output width/height/fileSize/mimeType are returned on the `EditorResult`.