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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
  3. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  4. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  5. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  6. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  7. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  8. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
  9. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  10. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  11. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
  12. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
  13. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  14. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  15. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  16. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  17. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  18. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  19. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  20. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  21. package/docs/editor-ui-validation.md +22 -0
  22. package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
  23. package/ios/Editor/UI/CropPanel.swift +97 -0
  24. package/ios/Editor/UI/EditorPanel.swift +334 -0
  25. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  26. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  27. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  28. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  29. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  30. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  31. package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
  32. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
  33. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  34. package/ios/Photo/UI/CropOverlayView.swift +96 -169
  35. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  36. package/ios/Photo/UI/LayerOverlayView.swift +27 -10
  37. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  38. package/ios/Video/Export/VideoExporter.swift +10 -1
  39. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  40. package/ios/Video/Render/VideoTransformState.swift +1 -0
  41. package/package.json +1 -1
@@ -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,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.