react-native-photo-video-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,319 @@
1
+ package com.photovideoeditor.video.export
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.media.MediaMetadataRetriever
7
+ import android.net.Uri
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import androidx.media3.common.Effect
11
+ import androidx.media3.common.MediaItem
12
+ import androidx.media3.common.MimeTypes
13
+ import androidx.media3.common.util.Size
14
+ import androidx.media3.effect.BitmapOverlay
15
+ import androidx.media3.effect.Brightness
16
+ import androidx.media3.effect.Contrast
17
+ import androidx.media3.effect.HslAdjustment
18
+ import androidx.media3.effect.OverlayEffect
19
+ import androidx.media3.effect.Presentation
20
+ import androidx.media3.effect.ScaleAndRotateTransformation
21
+ import androidx.media3.effect.TextureOverlay
22
+ import androidx.media3.transformer.Composition
23
+ import androidx.media3.transformer.EditedMediaItem
24
+ import androidx.media3.transformer.EditedMediaItemSequence
25
+ import androidx.media3.transformer.Effects
26
+ import androidx.media3.transformer.ExportException
27
+ import androidx.media3.transformer.ExportResult
28
+ import androidx.media3.transformer.ProgressHolder
29
+ import androidx.media3.transformer.Transformer
30
+ import com.photovideoeditor.files.SourceResolver
31
+ import com.photovideoeditor.photo.render.PhotoLayer
32
+ import com.photovideoeditor.photo.render.PhotoLayerRenderer
33
+ import com.photovideoeditor.video.render.VideoClip
34
+ import com.photovideoeditor.video.render.VideoTransformState
35
+ import org.json.JSONObject
36
+ import java.io.File
37
+ import java.util.UUID
38
+ import java.util.concurrent.ConcurrentHashMap
39
+
40
+ data class VideoExportResult(
41
+ val uri: String,
42
+ val width: Int,
43
+ val height: Int,
44
+ val durationMs: Long,
45
+ val fileSize: Long,
46
+ val mimeType: String
47
+ )
48
+
49
+ class VideoExportException(val code: String, message: String) : Exception(message)
50
+
51
+ /**
52
+ * Wraps Media3 Transformer to apply trim/rotate/aspect-ratio/overlays
53
+ * to the full-resolution source video and mux an H.264/AAC MP4. The source
54
+ * file is never modified. Must be constructed and used from a thread with a
55
+ * Looper (Transformer requirement) — the main thread satisfies this.
56
+ *
57
+ * Milestone 7 (multi-clip timeline): each [VideoClip] becomes its own
58
+ * [EditedMediaItem] (own trim, same global rotate/aspect/filter effects),
59
+ * concatenated gapless via a single [EditedMediaItemSequence] passed to
60
+ * [Composition] — verified against the actual Media3 1.4.1 bytecode (`javap`)
61
+ * before writing this: `EditedMediaItemSequence(List<EditedMediaItem>)`,
62
+ * `Composition.Builder(EditedMediaItemSequence, EditedMediaItemSequence...)`,
63
+ * and `Transformer.start(Composition, String)` all exist in this version.
64
+ * Overlay timing is authored against the *global* composed timeline (matching
65
+ * `VideoEditSession.globalPositionMs()`), so each clip's overlay callback
66
+ * offsets its own (per-item, zero-based) `presentationTimeUs` by the summed
67
+ * trimmed duration of the clips before it to recover the global position.
68
+ *
69
+ * `state.speed` is intentionally NOT applied here. Media3 1.4.1 (the version
70
+ * this module is pinned to, verified via a live Gradle dependency resolution)
71
+ * does not expose `SpeedProvider`/`TimestampAdjustment(SpeedProvider)` — that
72
+ * landed in a later release. Bumping to a newer Media3 version to get it broke
73
+ * other already-verified APIs used elsewhere in this file (`Transformer.Builder
74
+ * .setTransformationRequest`, `OverlayEffect`'s list parameter type), so rather
75
+ * than chase version bumps mid-session and risk destabilizing the working
76
+ * export pipeline, this is a deliberate, documented scope cut — see
77
+ * docs/video-editor.md. Preview still honors speed via `ExoPlayer.setPlaybackSpeed`.
78
+ */
79
+ class VideoExporter(private val context: Context) {
80
+ private var transformer: Transformer? = null
81
+ private var outputFile: File? = null
82
+ private var polling = false
83
+ private val handler = Handler(Looper.getMainLooper())
84
+ private val overlayBitmaps = ConcurrentHashMap.newKeySet<Bitmap>()
85
+
86
+ fun export(
87
+ clips: List<VideoClip>,
88
+ state: VideoTransformState,
89
+ layers: List<PhotoLayer>,
90
+ exportOptions: JSONObject?,
91
+ onProgress: (Float) -> Unit,
92
+ onComplete: (VideoExportResult) -> Unit,
93
+ onError: (VideoExportException) -> Unit
94
+ ) {
95
+ releaseOverlayBitmaps()
96
+ if (clips.isEmpty()) {
97
+ onError(VideoExportException("E_SOURCE_NOT_FOUND", "There are no clips to export."))
98
+ return
99
+ }
100
+ val totalDurationMs = clips.sumOf { it.trimmedDurationMs() }
101
+
102
+ val outputDir = File(context.cacheDir, "photovideoeditor").apply { mkdirs() }
103
+ val output = File(outputDir, "${UUID.randomUUID()}.mp4")
104
+ outputFile = output
105
+
106
+ val (sourceWidth, sourceHeight) = readUprightVideoSize(mediaUri(clips.first().sourceUri))
107
+
108
+ val editedItems = mutableListOf<EditedMediaItem>()
109
+ var precedingDurationMs = 0L
110
+ for (clip in clips) {
111
+ val effects = mutableListOf<Effect>()
112
+ if (state.rotationDegrees != 0) {
113
+ effects.add(
114
+ ScaleAndRotateTransformation.Builder()
115
+ .setRotationDegrees(state.rotationDegrees.toFloat())
116
+ .build()
117
+ )
118
+ }
119
+ state.aspectRatio?.let { ratio ->
120
+ effects.add(Presentation.createForAspectRatio(ratio, Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP))
121
+ }
122
+
123
+ // Video-level filters: export-only for now, no live preview — see docs/video-editor.md.
124
+ if (state.brightness != 0f) effects.add(Brightness(state.brightness / 100f))
125
+ if (state.contrast != 0f) effects.add(Contrast(state.contrast / 100f))
126
+ if (state.saturation != 0f) {
127
+ effects.add(HslAdjustment.Builder().adjustSaturation(state.saturation / 100f).build())
128
+ }
129
+
130
+ // Each overlay has its own [PhotoLayer.startMs]/[endMs] on the *global* composed timeline, so the
131
+ // composited bitmap is genuinely time-varying across clip boundaries. Re-rendering on every decoded
132
+ // frame would be wasteful since the active-layer set only actually changes at a handful of
133
+ // timestamps (each overlay's start/end), so the rendered result is cached and only recomputed when
134
+ // the active-layer signature changes between frames.
135
+ //
136
+ // Timestamp caveat (unverified — no device available to confirm): this item's own `getBitmap`
137
+ // presentationTimeUs is assumed to be zero-based at this clip's own trimmed start (matching
138
+ // Transformer's typical per-item effect-pipeline behavior), so the summed duration of preceding
139
+ // clips is added to recover the global timeline position that the overlay's startMs/endMs are
140
+ // authored against.
141
+ if (layers.isNotEmpty()) {
142
+ val clipOffsetMs = precedingDurationMs
143
+ var cachedSignature: List<String>? = null
144
+ var cachedBitmap: Bitmap? = null
145
+ effects.add(
146
+ OverlayEffect(
147
+ listOf<TextureOverlay>(
148
+ object : BitmapOverlay() {
149
+ @Volatile private var outputWidth = sourceWidth.coerceAtLeast(2)
150
+ @Volatile private var outputHeight = sourceHeight.coerceAtLeast(2)
151
+
152
+ override fun configure(videoSize: Size) {
153
+ 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)
163
+ }
164
+
165
+ override fun getBitmap(presentationTimeUs: Long): Bitmap {
166
+ val positionMs = presentationTimeUs / 1000 + clipOffsetMs
167
+ val activeLayers = layers.filter { it.isActiveAt(positionMs, totalDurationMs) }
168
+ val signature = activeLayers.map { it.id }
169
+ if (signature != cachedSignature || cachedBitmap == null) {
170
+ val input = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
171
+ cachedBitmap = PhotoLayerRenderer.render(input, activeLayers) { uri -> resolveImageUri(context, uri) }
172
+ if (cachedBitmap !== input && !input.isRecycled) input.recycle()
173
+ // BitmapOverlay may retain the returned frame in the GL
174
+ // pipeline. Keep it alive until Transformer terminates.
175
+ cachedBitmap?.let { overlayBitmaps.add(it) }
176
+ cachedSignature = signature
177
+ }
178
+ return cachedBitmap!!
179
+ }
180
+ }
181
+ )
182
+ )
183
+ )
184
+ }
185
+
186
+ val mediaItem = MediaItem.Builder()
187
+ .setUri(mediaUri(clip.sourceUri))
188
+ .setClippingConfiguration(
189
+ MediaItem.ClippingConfiguration.Builder()
190
+ .setStartPositionMs(clip.trimStartMs)
191
+ .setEndPositionMs(clip.effectiveTrimEndMs())
192
+ .build()
193
+ )
194
+ .build()
195
+
196
+ // `state.speed` is intentionally not applied here — see the class doc comment above.
197
+ editedItems.add(
198
+ EditedMediaItem.Builder(mediaItem)
199
+ .setEffects(Effects(emptyList(), effects))
200
+ .build()
201
+ )
202
+ precedingDurationMs += clip.trimmedDurationMs()
203
+ }
204
+
205
+ val composition = Composition.Builder(EditedMediaItemSequence(editedItems)).build()
206
+
207
+ val transformerBuilder = Transformer.Builder(context)
208
+ .setVideoMimeType(MimeTypes.VIDEO_H264)
209
+ .setAudioMimeType(MimeTypes.AUDIO_AAC)
210
+
211
+ val builtTransformer = transformerBuilder
212
+ .addListener(object : Transformer.Listener {
213
+ override fun onCompleted(composition: Composition, exportResult: ExportResult) {
214
+ polling = false
215
+ transformer = null
216
+ // The completed file now belongs to the caller. cancel()/destroy must
217
+ // never delete it after the editor activity finishes.
218
+ outputFile = null
219
+ onComplete(
220
+ VideoExportResult(
221
+ uri = Uri.fromFile(output).toString(),
222
+ width = exportResult.width.coerceAtLeast(0),
223
+ height = exportResult.height.coerceAtLeast(0),
224
+ durationMs = if (exportResult.durationMs > 0) exportResult.durationMs else totalDurationMs,
225
+ fileSize = output.length(),
226
+ mimeType = "video/mp4"
227
+ )
228
+ )
229
+ releaseOverlayBitmaps()
230
+ }
231
+
232
+ override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
233
+ polling = false
234
+ transformer = null
235
+ output.delete()
236
+ releaseOverlayBitmaps()
237
+ onError(VideoExportException("E_EXPORT_FAILED", exportException.message ?: "Unable to export the video."))
238
+ }
239
+ })
240
+ .build()
241
+ transformer = builtTransformer
242
+ try {
243
+ builtTransformer.start(composition, output.absolutePath)
244
+ startPolling(onProgress)
245
+ } catch (error: Exception) {
246
+ polling = false
247
+ transformer = null
248
+ output.delete()
249
+ releaseOverlayBitmaps()
250
+ onError(VideoExportException("E_EXPORT_FAILED", error.message ?: "Unable to start video export."))
251
+ }
252
+ }
253
+
254
+ private fun resolveImageUri(context: Context, uri: String): Bitmap? {
255
+ val path = SourceResolver.resolvePath(context, uri, "pve_video_layer_image_export") ?: return null
256
+ return BitmapFactory.decodeFile(path)
257
+ }
258
+
259
+ /** The video's decoded frame size after its own rotation metadata is applied (i.e. as it will actually be displayed/exported before any additional user rotation/crop). */
260
+ private fun readUprightVideoSize(uri: Uri): Pair<Int, Int> {
261
+ val retriever = MediaMetadataRetriever()
262
+ return try {
263
+ retriever.setDataSource(context, uri)
264
+ val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 1280
265
+ val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 720
266
+ val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
267
+ if (rotation == 90 || rotation == 270) height to width else width to height
268
+ } catch (_: Exception) {
269
+ 1280 to 720
270
+ } finally {
271
+ retriever.release()
272
+ }
273
+ }
274
+
275
+ private fun mediaUri(value: String): Uri {
276
+ val parsed = Uri.parse(value)
277
+ return if (parsed.scheme == null) Uri.fromFile(File(value)) else parsed
278
+ }
279
+
280
+ private fun startPolling(onProgress: (Float) -> Unit) {
281
+ polling = true
282
+ val holder = ProgressHolder()
283
+ val runnable = object : Runnable {
284
+ override fun run() {
285
+ val current = transformer
286
+ if (current == null || !polling) return
287
+ try {
288
+ current.getProgress(holder)
289
+ onProgress((holder.progress.coerceIn(0, 100)) / 100f)
290
+ if (polling) handler.postDelayed(this, 300)
291
+ } catch (_: IllegalStateException) {
292
+ // Transformer completed/cancelled between the null check and poll.
293
+ }
294
+ }
295
+ }
296
+ handler.postDelayed(runnable, 300)
297
+ }
298
+
299
+ /** Cancels an in-progress export and deletes the partial output file. */
300
+ fun cancel() {
301
+ polling = false
302
+ handler.removeCallbacksAndMessages(null)
303
+ transformer?.cancel()
304
+ transformer = null
305
+ outputFile?.delete()
306
+ outputFile = null
307
+ releaseOverlayBitmaps()
308
+ }
309
+
310
+ private fun releaseOverlayBitmaps() {
311
+ overlayBitmaps.forEach { if (!it.isRecycled) it.recycle() }
312
+ overlayBitmaps.clear()
313
+ }
314
+
315
+ 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
318
+ }
319
+ }
@@ -0,0 +1,20 @@
1
+ package com.photovideoeditor.video.render
2
+
3
+ import java.util.UUID
4
+
5
+ /**
6
+ * One segment in the multi-clip timeline. Every clip in this milestone shares
7
+ * the same source file (created via Split/Duplicate of one input) — importing
8
+ * separate additional source files from a system picker is a follow-up, not
9
+ * attempted here.
10
+ */
11
+ data class VideoClip(
12
+ val id: String = UUID.randomUUID().toString(),
13
+ val sourceUri: String,
14
+ val originalDurationMs: Long,
15
+ var trimStartMs: Long = 0L,
16
+ var trimEndMs: Long = 0L
17
+ ) {
18
+ fun effectiveTrimEndMs(): Long = if (trimEndMs in 1..originalDurationMs) trimEndMs else originalDurationMs
19
+ fun trimmedDurationMs(): Long = (effectiveTrimEndMs() - trimStartMs).coerceAtLeast(0L)
20
+ }
@@ -0,0 +1,136 @@
1
+ package com.photovideoeditor.video.render
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import androidx.media3.common.MediaItem
6
+ import androidx.media3.common.PlaybackException
7
+ import androidx.media3.common.Player
8
+ import androidx.media3.exoplayer.ExoPlayer
9
+ import com.photovideoeditor.photo.render.PhotoLayerStack
10
+ import java.io.File
11
+
12
+ /**
13
+ * Owns the ExoPlayer preview instance, the ordered [VideoClip] list (the
14
+ * multi-clip timeline, Milestone 7), and the [VideoTransformState] that
15
+ * applies globally across the whole composed sequence. The source is never
16
+ * modified — [com.photovideoeditor.video.export.VideoExporter] reads the
17
+ * clip list again to produce the exported file.
18
+ */
19
+ class VideoEditSession(context: Context, val sourceUri: String) {
20
+ val player: ExoPlayer = ExoPlayer.Builder(context).build()
21
+
22
+ /** Text/sticker/shape overlays. Reuses the photo layer model/stack — see `PhotoLayer.startMs`/`endMs` for timing. */
23
+ val layerStack = PhotoLayerStack()
24
+
25
+ var clips: List<VideoClip> = emptyList()
26
+ private set
27
+
28
+ var state: VideoTransformState = VideoTransformState()
29
+ private set
30
+
31
+ /** Fires once the source is probed and the initial single-clip timeline is ready. */
32
+ var onClipsReady: (() -> Unit)? = null
33
+ set(value) {
34
+ field = value
35
+ // Activity view construction may finish after a very fast local source
36
+ // reaches STATE_READY. Never lose that one-shot notification.
37
+ if (value != null && clips.isNotEmpty()) value.invoke()
38
+ }
39
+
40
+ /** Reports decoder/source failures instead of leaving a blank player forever. */
41
+ var onPlaybackError: ((String) -> Unit)? = null
42
+ set(value) {
43
+ field = value
44
+ pendingPlaybackError?.let { error -> value?.invoke(error) }
45
+ }
46
+
47
+ private var pendingPlaybackError: String? = null
48
+ private var initialTimelineCreated = false
49
+
50
+ init {
51
+ player.addListener(object : Player.Listener {
52
+ override fun onPlaybackStateChanged(playbackState: Int) {
53
+ if (playbackState == Player.STATE_READY && !initialTimelineCreated && player.duration > 0) {
54
+ initialTimelineCreated = true
55
+ clips = listOf(VideoClip(sourceUri = sourceUri, originalDurationMs = player.duration))
56
+ rebuildPlayerTimeline(preserveIndex = 0)
57
+ onClipsReady?.invoke()
58
+ }
59
+ }
60
+
61
+ override fun onPlayerError(error: PlaybackException) {
62
+ val message = error.message ?: "The selected video could not be played."
63
+ pendingPlaybackError = message
64
+ onPlaybackError?.invoke(message)
65
+ }
66
+ })
67
+ player.setMediaItem(MediaItem.fromUri(playbackUri(sourceUri)))
68
+ player.prepare()
69
+ }
70
+
71
+ /** Sum of every clip's trimmed duration — the length of the composed timeline. */
72
+ val durationMs: Long get() = clips.sumOf { it.trimmedDurationMs() }
73
+
74
+ fun update(transform: (VideoTransformState) -> VideoTransformState) {
75
+ state = transform(state)
76
+ }
77
+
78
+ /** Applies [transform] to the clip list and rebuilds the player's playlist to match. */
79
+ fun updateClips(transform: (List<VideoClip>) -> List<VideoClip>) {
80
+ val previousIndex = player.currentMediaItemIndex
81
+ clips = transform(clips)
82
+ rebuildPlayerTimeline(preserveIndex = previousIndex)
83
+ }
84
+
85
+ private fun rebuildPlayerTimeline(preserveIndex: Int) {
86
+ if (clips.isEmpty()) return
87
+ val items = clips.map { clip ->
88
+ MediaItem.Builder()
89
+ .setUri(Uri.parse(clip.sourceUri))
90
+ .setClippingConfiguration(
91
+ MediaItem.ClippingConfiguration.Builder()
92
+ .setStartPositionMs(clip.trimStartMs)
93
+ .setEndPositionMs(clip.effectiveTrimEndMs())
94
+ .build()
95
+ )
96
+ .build()
97
+ }
98
+ player.setMediaItems(items)
99
+ player.prepare()
100
+ val safeIndex = preserveIndex.coerceIn(0, clips.size - 1)
101
+ player.seekTo(safeIndex, 0)
102
+ }
103
+
104
+ /** Position within the whole multi-clip timeline: preceding clips' trimmed durations + position in the current clip. */
105
+ fun globalPositionMs(): Long {
106
+ if (clips.isEmpty()) return 0
107
+ val index = player.currentMediaItemIndex.coerceIn(0, clips.size - 1)
108
+ val preceding = clips.take(index).sumOf { it.trimmedDurationMs() }
109
+ return preceding + player.currentPosition.coerceAtLeast(0)
110
+ }
111
+
112
+ /** Seeks the player to a position expressed in the composed (global) timeline. */
113
+ fun seekToGlobalMs(targetMs: Long) {
114
+ if (clips.isEmpty()) return
115
+ var remaining = targetMs.coerceIn(0, durationMs)
116
+ for ((index, clip) in clips.withIndex()) {
117
+ val clipDuration = clip.trimmedDurationMs()
118
+ if (remaining <= clipDuration || index == clips.size - 1) {
119
+ player.seekTo(index, remaining.coerceIn(0, clipDuration))
120
+ return
121
+ }
122
+ remaining -= clipDuration
123
+ }
124
+ }
125
+
126
+ fun release() {
127
+ onClipsReady = null
128
+ onPlaybackError = null
129
+ player.release()
130
+ }
131
+
132
+ private fun playbackUri(value: String): Uri {
133
+ val parsed = Uri.parse(value)
134
+ return if (parsed.scheme == null) Uri.fromFile(File(value)) else parsed
135
+ }
136
+ }
@@ -0,0 +1,31 @@
1
+ package com.photovideoeditor.video.render
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.media.MediaMetadataRetriever
6
+ import android.net.Uri
7
+ import java.io.File
8
+
9
+ /**
10
+ * Extracts a fixed number of evenly spaced thumbnails for the trim strip.
11
+ * Must be called from a background thread — decoding is not cheap.
12
+ */
13
+ object VideoThumbnailGenerator {
14
+ fun generate(context: Context, sourceUri: String, durationMs: Long, count: Int = 12): List<Bitmap> {
15
+ if (durationMs <= 0) return emptyList()
16
+ val retriever = MediaMetadataRetriever()
17
+ return try {
18
+ val parsed = Uri.parse(sourceUri)
19
+ retriever.setDataSource(context, if (parsed.scheme == null) Uri.fromFile(File(sourceUri)) else parsed)
20
+ val step = durationMs / count
21
+ (0 until count).mapNotNull { index ->
22
+ val timeUs = (index * step + step / 2) * 1000
23
+ retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
24
+ }
25
+ } catch (_: Exception) {
26
+ emptyList()
27
+ } finally {
28
+ retriever.release()
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,48 @@
1
+ package com.photovideoeditor.video.render
2
+
3
+ /**
4
+ * Non-destructive video edit state that applies globally across the whole
5
+ * multi-clip timeline (Milestone 7) — trim now lives per clip on [VideoClip]
6
+ * instead of here. `coverFrameMs` is a position in the composed timeline
7
+ * (sum of preceding clips' trimmed durations + offset into the current one),
8
+ * not a position within any single clip's own source file.
9
+ */
10
+ data class VideoTransformState(
11
+ val rotationDegrees: Int = 0,
12
+ val aspectRatio: Float? = null,
13
+ val coverFrameMs: Long = 0L,
14
+ /** Preview-only for now: cycled via the Speed tool. Export does not yet honor this — see docs/video-editor.md. */
15
+ val speed: Float = 1f,
16
+ /** -100..100. Export-only for now — no live preview. See docs/video-editor.md. */
17
+ val brightness: Float = 0f,
18
+ /** -100..100. Export-only for now — no live preview. */
19
+ val contrast: Float = 0f,
20
+ /** -100..100. Export-only for now — no live preview. */
21
+ val saturation: Float = 0f
22
+ ) {
23
+ fun rotatedRight(): VideoTransformState = copy(rotationDegrees = (rotationDegrees + 90) % 360)
24
+ fun withAspectRatio(ratio: Float?): VideoTransformState = copy(aspectRatio = ratio)
25
+ fun withCoverFrame(atMs: Long): VideoTransformState = copy(coverFrameMs = atMs)
26
+
27
+ fun filterValue(key: String): Float = when (key) {
28
+ "brightness" -> brightness
29
+ "contrast" -> contrast
30
+ "saturation" -> saturation
31
+ else -> 0f
32
+ }
33
+
34
+ fun withFilter(key: String, value: Float): VideoTransformState = when (key) {
35
+ "brightness" -> copy(brightness = value)
36
+ "contrast" -> copy(contrast = value)
37
+ "saturation" -> copy(saturation = value)
38
+ else -> this
39
+ }
40
+
41
+ fun withFiltersReset(): VideoTransformState = copy(brightness = 0f, contrast = 0f, saturation = 0f)
42
+
43
+ private val speedSteps = listOf(0.5f, 1f, 1.5f, 2f)
44
+ fun cycledSpeed(): VideoTransformState {
45
+ val currentIndex = speedSteps.indexOf(speed).takeIf { it >= 0 } ?: 1
46
+ return copy(speed = speedSteps[(currentIndex + 1) % speedSteps.size])
47
+ }
48
+ }
@@ -0,0 +1,104 @@
1
+ package com.photovideoeditor.video.ui
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.Paint
8
+ import android.graphics.RectF
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import kotlin.math.abs
12
+
13
+ /**
14
+ * Dual-handle trim range selector drawn over a horizontal thumbnail strip.
15
+ * `startFraction`/`endFraction` are fractions (0..1) of the video's total
16
+ * duration; a minimum gap between them is enforced so a trim can never
17
+ * collapse to (or past) zero length.
18
+ */
19
+ class TrimRangeView(context: Context) : View(context) {
20
+ var thumbnails: List<Bitmap> = emptyList()
21
+ set(value) {
22
+ field = value
23
+ invalidate()
24
+ }
25
+ var startFraction: Float = 0f
26
+ private set
27
+ var endFraction: Float = 1f
28
+ private set
29
+ var minDurationFraction: Float = 0.02f
30
+ var onRangeChanged: ((start: Float, end: Float) -> Unit)? = null
31
+
32
+ private val handleWidthPx = 28f
33
+ private var activeHandle = Handle.NONE
34
+
35
+ private enum class Handle { NONE, START, END }
36
+
37
+ private val dimPaint = Paint().apply { color = Color.argb(160, 0, 0, 0) }
38
+ private val handlePaint = Paint().apply { color = Color.WHITE }
39
+ private val borderPaint = Paint().apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f }
40
+ private val backgroundPaint = Paint().apply { color = Color.DKGRAY }
41
+
42
+ fun setRange(start: Float, end: Float) {
43
+ startFraction = start.coerceIn(0f, 1f)
44
+ endFraction = end.coerceIn(startFraction, 1f)
45
+ invalidate()
46
+ }
47
+
48
+ override fun onDraw(canvas: Canvas) {
49
+ super.onDraw(canvas)
50
+ val w = width.toFloat()
51
+ val h = height.toFloat()
52
+ if (w <= 0 || h <= 0) return
53
+ if (thumbnails.isNotEmpty()) {
54
+ val thumbWidth = w / thumbnails.size
55
+ thumbnails.forEachIndexed { index, bitmap ->
56
+ canvas.drawBitmap(bitmap, null, RectF(index * thumbWidth, 0f, (index + 1) * thumbWidth, h), null)
57
+ }
58
+ } else {
59
+ canvas.drawRect(0f, 0f, w, h, backgroundPaint)
60
+ }
61
+ val startX = startFraction * w
62
+ val endX = endFraction * w
63
+ canvas.drawRect(0f, 0f, startX, h, dimPaint)
64
+ canvas.drawRect(endX, 0f, w, h, dimPaint)
65
+ canvas.drawRect(startX, 0f, endX, h, borderPaint)
66
+ canvas.drawRect(startX - handleWidthPx / 2, 0f, startX + handleWidthPx / 2, h, handlePaint)
67
+ canvas.drawRect(endX - handleWidthPx / 2, 0f, endX + handleWidthPx / 2, h, handlePaint)
68
+ }
69
+
70
+ override fun onTouchEvent(event: MotionEvent): Boolean {
71
+ val w = width.toFloat()
72
+ if (w <= 0) return false
73
+ when (event.actionMasked) {
74
+ MotionEvent.ACTION_DOWN -> {
75
+ val startX = startFraction * w
76
+ val endX = endFraction * w
77
+ activeHandle = when {
78
+ abs(event.x - startX) < handleWidthPx -> Handle.START
79
+ abs(event.x - endX) < handleWidthPx -> Handle.END
80
+ else -> Handle.NONE
81
+ }
82
+ return activeHandle != Handle.NONE
83
+ }
84
+ MotionEvent.ACTION_MOVE -> {
85
+ if (activeHandle == Handle.NONE) return false
86
+ val fraction = (event.x / w).coerceIn(0f, 1f)
87
+ when (activeHandle) {
88
+ Handle.START -> startFraction = fraction.coerceAtMost(endFraction - minDurationFraction).coerceAtLeast(0f)
89
+ Handle.END -> endFraction = fraction.coerceAtLeast(startFraction + minDurationFraction).coerceAtMost(1f)
90
+ Handle.NONE -> return false
91
+ }
92
+ invalidate()
93
+ onRangeChanged?.invoke(startFraction, endFraction)
94
+ return true
95
+ }
96
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
97
+ val handled = activeHandle != Handle.NONE
98
+ activeHandle = Handle.NONE
99
+ return handled
100
+ }
101
+ }
102
+ return false
103
+ }
104
+ }
@@ -0,0 +1,9 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="960"
5
+ android:viewportHeight="960">
6
+ <group android:translateX="-0" android:translateY="960">
7
+ <path android:fillColor="#FFFFFF" android:pathData="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
8
+ </group>
9
+ </vector>
@@ -0,0 +1,9 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="960"
5
+ android:viewportHeight="960">
6
+ <group android:translateX="-0" android:translateY="960">
7
+ <path android:fillColor="#FFFFFF" android:pathData="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/>
8
+ </group>
9
+ </vector>