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,2064 @@
1
+ import AVFoundation
2
+ import AVKit
3
+ import PhotosUI
4
+ import UIKit
5
+
6
+ /// Distinguishes which flow a picked image is for, since photo/video and sticker/overlay all share
7
+ /// one `PHPickerViewControllerDelegate` callback.
8
+ private enum ImagePickPurpose {
9
+ case photoSticker, photoOverlay, videoSticker, videoOverlay
10
+ }
11
+
12
+ final class PhotoVideoEditorViewController: UIViewController {
13
+ private let uri: String
14
+ private let mediaType: String
15
+ private let features: [String: Any]
16
+ private let theme: [String: Any]
17
+ private let exportOptions: [String: Any]
18
+ private let stickerAssets: [[String: Any]]
19
+ var completion: ((PhotoVideoEditorOutcome) -> Void)?
20
+
21
+ private var photoSession: PhotoEditSession?
22
+ private var cropMode = false
23
+ private var filtersMode = false
24
+ private var stickersMode = false
25
+ private var pendingPickPurpose: ImagePickPurpose?
26
+ private var activeAdjustmentKey = "brightness"
27
+ private var selectedLayerID: String?
28
+ private var activeLayerPropertyKey = "scale"
29
+ private var dragStartSnapshot: [PhotoLayer]?
30
+ private var photoImageView: ZoomableImageView?
31
+ private var cropOverlay: CropOverlayView?
32
+ private var layerOverlay: LayerOverlayView?
33
+ private var straightenSlider: UISlider?
34
+ private var cropSubBar: UIView?
35
+ private var adjustmentSlider: UISlider?
36
+ private var filtersSubBar: UIView?
37
+ private var stickersSubBar: UIView?
38
+ private var layerToolBar: UIView?
39
+ private var layerPropertySlider: UISlider?
40
+ private var layerColorSwatchRow: UIView?
41
+ private var mainToolBar: UIView?
42
+ private var undoButton: UIButton?
43
+ private var redoButton: UIButton?
44
+ private var progressOverlay: UIView?
45
+ private var photoPreviewRenderPending = false
46
+ private var comparingOriginal = false
47
+ private var selectedPhotoExportFormat: String?
48
+
49
+ private var videoSession: VideoEditSession?
50
+ private var videoExporter: VideoExporter?
51
+ private var videoAspectMode = false
52
+ private var trimRangeView: TrimRangeView?
53
+ private var positionSlider: UISlider?
54
+ private var playPauseButton: UIButton?
55
+ private var timeLabel: UILabel?
56
+ private var videoAspectSubBar: UIView?
57
+ private var videoToolBar: UIView?
58
+ private var videoExportProgressOverlay: UIView?
59
+ private var positionPollTimer: Timer?
60
+ private var videoOverlayImageView: UIImageView?
61
+ private var videoOverlayRenderKey: String?
62
+ private var videoNaturalSize: CGSize?
63
+ private var videoLayerOverlay: LayerOverlayView?
64
+ private var videoLayerToolBar: UIView?
65
+ private var videoLayerPropertySlider: UISlider?
66
+ private var selectedVideoLayerID: String?
67
+ private var activeVideoLayerPropertyKey = "scale"
68
+ private var videoDragStartSnapshot: [PhotoLayer]?
69
+ private var videoLayerPropertyButtons: [String: UIButton] = [:]
70
+ private var seekingPosition = false
71
+ private var selectedClipIndex = 0
72
+ private var clipStripBar: UIStackView?
73
+ private var clipStripScroll: UIScrollView?
74
+ private var timelineThumbnailRepository: TimelineThumbnailRepository?
75
+ private var timelineThumbnailRequest: TimelineThumbnailRepository.Request?
76
+ private var timelineThumbnailFrames = [UIImage?](repeating: nil, count: 12)
77
+
78
+ private var cropAspectButtons: [String: UIButton] = [:]
79
+ private var filterAdjustmentButtons: [String: UIButton] = [:]
80
+ private var filterPresetButtons: [String: UIButton] = [:]
81
+ private var layerPropertyButtons: [String: UIButton] = [:]
82
+ private var layerIconButtons: [String: UIButton] = [:]
83
+ private var videoToolButtons: [String: UIButton] = [:]
84
+ private var videoAspectButtons: [String: UIButton] = [:]
85
+
86
+ init(
87
+ uri: String,
88
+ mediaType: String,
89
+ features: [String: Any],
90
+ theme: [String: Any] = [:],
91
+ exportOptions: [String: Any] = [:],
92
+ stickerAssets: [[String: Any]] = []
93
+ ) {
94
+ self.uri = uri
95
+ self.mediaType = mediaType
96
+ self.features = features
97
+ self.theme = theme
98
+ self.exportOptions = exportOptions
99
+ self.stickerAssets = stickerAssets
100
+ super.init(nibName: nil, bundle: nil)
101
+ modalPresentationStyle = .fullScreen
102
+ }
103
+ required init?(coder: NSCoder) { nil }
104
+
105
+ /// Parses a "#RRGGBB" or "#AARRGGBB" hex string; returns nil for anything else so callers can fall back to a default.
106
+ private func color(_ key: String) -> UIColor? {
107
+ guard var hex = theme[key] as? String, hex.hasPrefix("#") else { return nil }
108
+ hex.removeFirst()
109
+ guard hex.count == 6 || hex.count == 8, let value = UInt64(hex, radix: 16) else { return nil }
110
+ let hasAlpha = hex.count == 8
111
+ let alpha = hasAlpha ? CGFloat((value >> 24) & 0xFF) / 255 : 1
112
+ let red = CGFloat((value >> 16) & 0xFF) / 255
113
+ let green = CGFloat((value >> 8) & 0xFF) / 255
114
+ let blue = CGFloat(value & 0xFF) / 255
115
+ return UIColor(red: red, green: green, blue: blue, alpha: alpha)
116
+ }
117
+
118
+ override var preferredStatusBarStyle: UIStatusBarStyle {
119
+ (theme["statusBarStyle"] as? String) == "dark" ? .darkContent : .lightContent
120
+ }
121
+
122
+ override func viewDidLoad() {
123
+ super.viewDidLoad()
124
+ if mediaType == "photo" {
125
+ buildPhotoEditor()
126
+ } else {
127
+ buildVideoEditor()
128
+ }
129
+ }
130
+
131
+ // MARK: - Photo editor (crop, rotate, straighten, export — Milestone 2)
132
+
133
+ private func buildPhotoEditor() {
134
+ let backgroundColor = color("backgroundColor") ?? DesignTokens.surfaceContainerLowest
135
+ let toolbarColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
136
+ let textColor = color("textColor") ?? DesignTokens.onSurface
137
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
138
+ view.backgroundColor = backgroundColor
139
+ setNeedsStatusBarAppearanceUpdate()
140
+
141
+ let session = PhotoEditSession(sourceUri: uri)
142
+ photoSession = session
143
+
144
+ let header = makeHeader(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
145
+ let preview = UIView(); preview.backgroundColor = .black
146
+ preview.layer.cornerRadius = DesignTokens.radiusLg
147
+ preview.clipsToBounds = true
148
+
149
+ guard let baseImage = session.baseImage else {
150
+ completion?(.failure(code: "E_SOURCE_UNREADABLE", message: "The selected photo could not be decoded."))
151
+ return
152
+ }
153
+
154
+ let imageView = ZoomableImageView()
155
+ imageView.image = session.renderPreview() ?? baseImage
156
+ photoImageView = imageView
157
+ preview.addSubview(imageView)
158
+ imageView.translatesAutoresizingMaskIntoConstraints = false
159
+ NSLayoutConstraint.activate([
160
+ imageView.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
161
+ imageView.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
162
+ imageView.topAnchor.constraint(equalTo: preview.topAnchor),
163
+ imageView.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
164
+ ])
165
+
166
+ let overlay = CropOverlayView()
167
+ overlay.isHidden = true
168
+ cropOverlay = overlay
169
+ preview.addSubview(overlay)
170
+ overlay.translatesAutoresizingMaskIntoConstraints = false
171
+ NSLayoutConstraint.activate([
172
+ overlay.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
173
+ overlay.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
174
+ overlay.topAnchor.constraint(equalTo: preview.topAnchor),
175
+ overlay.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
176
+ ])
177
+
178
+ let layers = LayerOverlayView()
179
+ layerOverlay = layers
180
+ preview.addSubview(layers)
181
+ layers.translatesAutoresizingMaskIntoConstraints = false
182
+ NSLayoutConstraint.activate([
183
+ layers.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
184
+ layers.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
185
+ layers.topAnchor.constraint(equalTo: preview.topAnchor),
186
+ layers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
187
+ ])
188
+
189
+ let metadataBadge = UILabel()
190
+ metadataBadge.text = " ● ORIGINAL • 100% "
191
+ metadataBadge.font = .systemFont(ofSize: 10, weight: .semibold)
192
+ metadataBadge.textColor = DesignTokens.onSurfaceVariant
193
+ metadataBadge.backgroundColor = DesignTokens.surfaceContainerHigh.withAlphaComponent(0.92)
194
+ metadataBadge.layer.cornerRadius = DesignTokens.radiusLg
195
+ metadataBadge.clipsToBounds = true
196
+ preview.addSubview(metadataBadge)
197
+ metadataBadge.translatesAutoresizingMaskIntoConstraints = false
198
+ NSLayoutConstraint.activate([
199
+ metadataBadge.centerXAnchor.constraint(equalTo: preview.centerXAnchor),
200
+ metadataBadge.topAnchor.constraint(equalTo: preview.topAnchor, constant: DesignTokens.spaceSm),
201
+ metadataBadge.heightAnchor.constraint(equalToConstant: 28),
202
+ ])
203
+
204
+ imageView.onBoundsChanged = { [weak self] bounds in
205
+ guard let self else { return }
206
+ if self.cropMode { overlay.setImageBounds(bounds, resetCrop: false) }
207
+ layers.setImageBounds(bounds)
208
+ }
209
+ overlay.onCropChanged = { left, top, right, bottom in
210
+ session.update { $0.setCrop(left: left, top: top, right: right, bottom: bottom) }
211
+ }
212
+ layers.onLayerTapped = { [weak self] id in self?.selectLayer(id) }
213
+ layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
214
+ guard let self, let session = self.photoSession else { return }
215
+ if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
216
+ session.layerStack.updateLive { list in list.map { $0.id == id ? {
217
+ var layer = $0; layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation; return layer
218
+ }() : $0 } }
219
+ layers.layers = session.layerStack.layers
220
+ self.schedulePhotoPreviewRender()
221
+ }
222
+ layers.onLayerTransformEnded = { [weak self] _ in
223
+ guard let self, let session = self.photoSession else { return }
224
+ if let snapshot = self.dragStartSnapshot { session.layerStack.commitSnapshot(snapshot) }
225
+ self.dragStartSnapshot = nil
226
+ self.onLayerStackChanged()
227
+ }
228
+
229
+ let straighten = UISlider()
230
+ straighten.minimumValue = -45
231
+ straighten.maximumValue = 45
232
+ straighten.value = 0
233
+ straighten.isHidden = true
234
+ straighten.addTarget(self, action: #selector(straightenChanged(_:)), for: .valueChanged)
235
+ straightenSlider = straighten
236
+
237
+ let cropBar = makeCropSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
238
+ cropBar.isHidden = true
239
+ cropSubBar = cropBar
240
+
241
+ let adjustmentSlider = UISlider()
242
+ adjustmentSlider.isHidden = true
243
+ adjustmentSlider.addTarget(self, action: #selector(adjustmentChanged(_:)), for: .valueChanged)
244
+ self.adjustmentSlider = adjustmentSlider
245
+
246
+ let filtersBar = makeFiltersSubBar(session: session, imageView: imageView, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
247
+ filtersBar.isHidden = true
248
+ filtersSubBar = filtersBar
249
+
250
+ let stickersBar = makeStickersSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
251
+ stickersBar.isHidden = true
252
+ stickersSubBar = stickersBar
253
+
254
+ let layerSlider = UISlider()
255
+ layerSlider.isHidden = true
256
+ layerSlider.addTarget(self, action: #selector(layerPropertyChanged(_:)), for: .valueChanged)
257
+ layerPropertySlider = layerSlider
258
+
259
+ let layerBar = makeLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
260
+ layerBar.isHidden = true
261
+ layerToolBar = layerBar
262
+
263
+ let colorSwatchRow = makeTextColorSwatchRow(session: session)
264
+ colorSwatchRow.isHidden = true
265
+ layerColorSwatchRow = colorSwatchRow
266
+
267
+ let toolbar = makePhotoToolBar(textColor: textColor, toolbarColor: toolbarColor)
268
+ mainToolBar = toolbar
269
+
270
+ let root = UIStackView(arrangedSubviews: [header, preview, straighten, cropBar, adjustmentSlider, filtersBar, stickersBar, layerSlider, colorSwatchRow, layerBar, toolbar])
271
+ root.axis = .vertical
272
+ root.translatesAutoresizingMaskIntoConstraints = false
273
+ view.addSubview(root)
274
+ NSLayoutConstraint.activate([
275
+ root.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
276
+ root.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
277
+ root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
278
+ root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
279
+ header.heightAnchor.constraint(equalToConstant: 60),
280
+ toolbar.heightAnchor.constraint(equalToConstant: 72),
281
+ cropBar.heightAnchor.constraint(equalToConstant: 100), // rotate row (44pt) + aspect chip row (56pt)
282
+ filtersBar.heightAnchor.constraint(equalToConstant: 56),
283
+ stickersBar.heightAnchor.constraint(equalToConstant: 56),
284
+ layerBar.heightAnchor.constraint(equalToConstant: 56),
285
+ colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
286
+ ])
287
+
288
+ let progress = UIView()
289
+ progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
290
+ progress.isHidden = true
291
+ let spinner = UIActivityIndicatorView(style: .large)
292
+ spinner.color = .white
293
+ spinner.startAnimating()
294
+ progress.addSubview(spinner)
295
+ spinner.translatesAutoresizingMaskIntoConstraints = false
296
+ NSLayoutConstraint.activate([spinner.centerXAnchor.constraint(equalTo: progress.centerXAnchor), spinner.centerYAnchor.constraint(equalTo: progress.centerYAnchor)])
297
+ progressOverlay = progress
298
+ view.addSubview(progress)
299
+ progress.translatesAutoresizingMaskIntoConstraints = false
300
+ NSLayoutConstraint.activate([
301
+ progress.leadingAnchor.constraint(equalTo: view.leadingAnchor),
302
+ progress.trailingAnchor.constraint(equalTo: view.trailingAnchor),
303
+ progress.topAnchor.constraint(equalTo: view.topAnchor),
304
+ progress.bottomAnchor.constraint(equalTo: view.bottomAnchor),
305
+ ])
306
+
307
+ view.layoutIfNeeded()
308
+ imageView.resetToFit()
309
+ }
310
+
311
+ /// Icon-tile + label node in the main tool rail (Studio Violet "Tool Node"). Mirrors Android's `ToolNode`.
312
+ private struct ToolNode {
313
+ let root: UIView
314
+ let tile: UIView
315
+ let icon: UIImageView
316
+ let label: UILabel
317
+ }
318
+
319
+ private var photoToolNodes: [String: ToolNode] = [:]
320
+ private weak var photoToolScroll: UIScrollView?
321
+ /// Backs `createToolNode`'s tap handling: each node's root view registers its action here, keyed by
322
+ /// its own identity, and a single shared `UITapGestureRecognizer` target (`self`) looks it up.
323
+ private var toolNodeActions: [ObjectIdentifier: () -> Void] = [:]
324
+
325
+ @objc private func handleToolNodeTap(_ recognizer: UITapGestureRecognizer) {
326
+ guard let view = recognizer.view else { return }
327
+ toolNodeActions[ObjectIdentifier(view)]?()
328
+ }
329
+
330
+ private func createToolNode(systemName: String, labelText: String, onTap: @escaping () -> Void) -> ToolNode {
331
+ let icon = UIImageView(image: UIImage(systemName: systemName))
332
+ icon.tintColor = DesignTokens.outline
333
+ icon.contentMode = .scaleAspectFit
334
+ let tile = UIView()
335
+ tile.backgroundColor = DesignTokens.surfaceContainerLow
336
+ tile.layer.cornerRadius = DesignTokens.radiusLg
337
+ tile.addSubview(icon)
338
+ icon.translatesAutoresizingMaskIntoConstraints = false
339
+ NSLayoutConstraint.activate([
340
+ icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
341
+ icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
342
+ icon.widthAnchor.constraint(equalToConstant: 22),
343
+ icon.heightAnchor.constraint(equalToConstant: 22),
344
+ ])
345
+ let label = UILabel()
346
+ label.text = labelText
347
+ label.font = .systemFont(ofSize: 11)
348
+ label.textColor = DesignTokens.outline
349
+ label.textAlignment = .center
350
+ let root = UIStackView(arrangedSubviews: [tile, label])
351
+ root.axis = .vertical
352
+ root.alignment = .center
353
+ root.spacing = 4
354
+ root.isUserInteractionEnabled = true
355
+ toolNodeActions[ObjectIdentifier(root)] = onTap
356
+ root.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleToolNodeTap(_:))))
357
+ root.applyPressScale()
358
+ NSLayoutConstraint.activate([
359
+ tile.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
360
+ tile.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
361
+ ])
362
+ return ToolNode(root: root, tile: tile, icon: icon, label: label)
363
+ }
364
+
365
+ private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
366
+ node.tile.backgroundColor = selected ? DesignTokens.surfaceContainer : DesignTokens.surfaceContainerLow
367
+ node.icon.tintColor = selected ? textColor : DesignTokens.outline
368
+ node.label.textColor = selected ? textColor : DesignTokens.outline
369
+ }
370
+
371
+ /// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
372
+ /// the Crop sub-bar, not as a top-level tool.
373
+ private func makePhotoToolBar(textColor: UIColor, toolbarColor: UIColor) -> UIView {
374
+ let tools: [(String, String, String)] = [
375
+ ("crop", "Crop", "crop"),
376
+ ("adjust", "Adjust", "slider.horizontal.3"),
377
+ ("filters", "Filters", "camera.filters"),
378
+ ("text", "Text", "textformat"),
379
+ ("stickers", "Stickers", "face.smiling"),
380
+ ("overlay", "Overlay", "photo.on.rectangle.angled"),
381
+ ("retouch", "Retouch", "wand.and.rays"),
382
+ ("layers", "Layers", "square.3.layers.3d"),
383
+ ("resize", "Resize", "aspectratio"),
384
+ ]
385
+ let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
386
+ photoToolScroll = scroll
387
+ let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 12
388
+ tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
389
+ let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
390
+ photoToolNodes[key] = node
391
+ stack.addArrangedSubview(node.root)
392
+ }
393
+ scroll.addSubview(stack)
394
+ stack.translatesAutoresizingMaskIntoConstraints = false
395
+ NSLayoutConstraint.activate([
396
+ stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
397
+ stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
398
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
399
+ stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
400
+ stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
401
+ ])
402
+ return scroll
403
+ }
404
+
405
+ private func makeCropSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
406
+ let presets: [(String, CGFloat?)] = [("Free", nil), ("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
407
+ let outer = UIStackView(); outer.axis = .vertical
408
+ outer.backgroundColor = toolbarColor
409
+
410
+ // Rotate row — moved here from the main tool rail to match the crop_transform_editor mockup.
411
+ let transformRow = UIStackView(arrangedSubviews: [
412
+ iconButton(systemName: "rotate.left", tint: textColor) { [weak self] in
413
+ guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
414
+ session.update { $0.rotateRight(); $0.rotateRight(); $0.rotateRight() }
415
+ self.schedulePhotoPreviewRender()
416
+ DispatchQueue.main.async {
417
+ imageView.resetToFit()
418
+ if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
419
+ }
420
+ },
421
+ iconButton(systemName: "rotate.right", tint: textColor) { [weak self] in
422
+ guard let self, let session = self.photoSession, let imageView = self.photoImageView else { return }
423
+ session.update { $0.rotateRight() }
424
+ self.schedulePhotoPreviewRender()
425
+ DispatchQueue.main.async {
426
+ imageView.resetToFit()
427
+ if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
428
+ }
429
+ },
430
+ ])
431
+ transformRow.axis = .horizontal
432
+ transformRow.alignment = .center
433
+ transformRow.distribution = .equalCentering
434
+ transformRow.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
435
+ outer.addArrangedSubview(transformRow)
436
+
437
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
438
+ let scroll = UIScrollView()
439
+ let presetStack = UIStackView(); presetStack.axis = .horizontal; presetStack.spacing = 4
440
+ presets.forEach { label, ratio in
441
+ let chip = button(label, color: textColor) { [weak self] in
442
+ self?.cropOverlay?.setAspectRatio(ratio)
443
+ session.update { $0.aspectRatio = ratio }
444
+ if let self { self.refreshSelection(self.cropAspectButtons, activeKey: label, textColor: textColor, primaryColor: primaryColor) }
445
+ }
446
+ cropAspectButtons[label] = chip
447
+ presetStack.addArrangedSubview(chip)
448
+ }
449
+ presetStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in self?.resetPhotoEdits() })
450
+ scroll.addSubview(presetStack)
451
+ presetStack.translatesAutoresizingMaskIntoConstraints = false
452
+ NSLayoutConstraint.activate([
453
+ presetStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
454
+ presetStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
455
+ presetStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
456
+ presetStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
457
+ presetStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
458
+ ])
459
+ bar.addArrangedSubview(scroll)
460
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setCropMode(false) })
461
+ bar.isLayoutMarginsRelativeArrangement = true
462
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
463
+ bar.heightAnchor.constraint(equalToConstant: 56).isActive = true
464
+ outer.addArrangedSubview(bar)
465
+ return outer
466
+ }
467
+
468
+ private func onPhotoToolTapped(_ key: String) {
469
+ guard let session = photoSession, photoImageView != nil else { return }
470
+ switch key {
471
+ case "crop":
472
+ setCropMode(true)
473
+ case "adjust", "filters", "retouch":
474
+ setFiltersMode(true, session: session)
475
+ case "text":
476
+ showTextInputDialog(session: session)
477
+ case "stickers":
478
+ setStickersMode(true)
479
+ case "overlay":
480
+ presentImagePicker(purpose: .photoOverlay)
481
+ case "resize":
482
+ setCropMode(true)
483
+ case "layers":
484
+ if let top = session.layerStack.layers.last { selectLayer(top.id) }
485
+ else { showToast("Add content to start a layer stack.") }
486
+ default:
487
+ showToast("\(key) is coming in a later milestone.")
488
+ }
489
+ }
490
+
491
+ // MARK: - Layers: text, selection, and transform/duplicate/reorder/lock/hide/delete
492
+ // with undo/redo (Milestone 4)
493
+
494
+ private func showTextInputDialog(session: PhotoEditSession) {
495
+ let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
496
+ alert.addTextField { $0.placeholder = "Text" }
497
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
498
+ alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
499
+ guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
500
+ var layer = PhotoLayer(type: .text)
501
+ layer.text = text
502
+ session.layerStack.commit { $0 + [layer] }
503
+ self?.selectLayer(layer.id)
504
+ })
505
+ present(alert, animated: true)
506
+ }
507
+
508
+ private func setStickersMode(_ enabled: Bool) {
509
+ stickersMode = enabled
510
+ stickersSubBar?.isHidden = !enabled
511
+ mainToolBar?.isHidden = enabled
512
+ refreshPhotoToolSelection()
513
+ }
514
+
515
+ private func makeStickersSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
516
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
517
+ let scroll = UIScrollView()
518
+ let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
519
+ PhotoLayerRenderer.builtinStickerIDs().forEach { id in
520
+ chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
521
+ var layer = PhotoLayer(type: .sticker)
522
+ layer.stickerId = id
523
+ session.layerStack.commit { $0 + [layer] }
524
+ self?.selectLayer(layer.id)
525
+ self?.setStickersMode(false)
526
+ })
527
+ }
528
+ consumerStickerAssets().forEach { asset in
529
+ chipStack.addArrangedSubview(button(asset.id, color: textColor) { [weak self] in
530
+ var layer = PhotoLayer(type: .sticker)
531
+ layer.stickerUri = asset.uri
532
+ session.layerStack.commit { $0 + [layer] }
533
+ self?.selectLayer(layer.id)
534
+ self?.setStickersMode(false)
535
+ })
536
+ }
537
+ chipStack.addArrangedSubview(button("Upload", color: textColor) { [weak self] in
538
+ self?.presentImagePicker(purpose: .photoSticker)
539
+ })
540
+ if features["onlineStickers"] as? Bool != false {
541
+ chipStack.addArrangedSubview(button("Browse", color: textColor) { [weak self] in
542
+ self?.presentOnlineStickerSheet(purpose: .photoSticker)
543
+ })
544
+ }
545
+ scroll.addSubview(chipStack)
546
+ chipStack.translatesAutoresizingMaskIntoConstraints = false
547
+ NSLayoutConstraint.activate([
548
+ chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
549
+ chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
550
+ chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
551
+ chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
552
+ chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
553
+ ])
554
+ bar.addArrangedSubview(scroll)
555
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setStickersMode(false) })
556
+ bar.backgroundColor = toolbarColor
557
+ bar.isLayoutMarginsRelativeArrangement = true
558
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
559
+ return bar
560
+ }
561
+
562
+ private func consumerStickerAssets() -> [(id: String, uri: String)] {
563
+ stickerAssets.compactMap { asset in
564
+ guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
565
+ return (asset["id"] as? String ?? uri, uri)
566
+ }
567
+ }
568
+
569
+ // MARK: - Native image picker (Upload chip / Overlay tool)
570
+
571
+ private func presentImagePicker(purpose: ImagePickPurpose) {
572
+ pendingPickPurpose = purpose
573
+ var configuration = PHPickerConfiguration()
574
+ configuration.filter = .images
575
+ configuration.selectionLimit = 1
576
+ let picker = PHPickerViewController(configuration: configuration)
577
+ picker.delegate = self
578
+ present(picker, animated: true)
579
+ }
580
+
581
+ /// Presents the OpenMoji "Browse online" bottom sheet, reusing `handlePickedImage` as the
582
+ /// insertion point once the user picks a sticker — same shared path the Upload flow uses.
583
+ private func presentOnlineStickerSheet(purpose: ImagePickPurpose) {
584
+ let sheet = OnlineStickerSheetViewController()
585
+ sheet.onStickerPicked = { [weak self] fileURL, image in
586
+ self?.handlePickedImage(fileURL, image: image, purpose: purpose)
587
+ }
588
+ sheet.sheetPresentationController?.detents = [.medium(), .large()]
589
+ present(sheet, animated: true)
590
+ }
591
+
592
+ private func writeTempImage(_ image: UIImage) -> URL? {
593
+ guard let data = image.pngData() ?? image.jpegData(compressionQuality: 0.92) else { return nil }
594
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
595
+ do {
596
+ try data.write(to: url)
597
+ return url
598
+ } catch {
599
+ return nil
600
+ }
601
+ }
602
+
603
+ /// Inserts a picked image as either a sticker layer or a generic movable/resizable overlay layer,
604
+ /// on the matching session (photo or video), based on which flow requested the picker.
605
+ private func handlePickedImage(_ fileURL: URL, image: UIImage, purpose: ImagePickPurpose) {
606
+ let uri = fileURL.absoluteString
607
+ let aspectRatio = max(image.size.width, 1) / max(image.size.height, 1)
608
+ switch purpose {
609
+ case .photoSticker:
610
+ guard let session = photoSession else { return }
611
+ var layer = PhotoLayer(type: .sticker)
612
+ layer.stickerUri = uri
613
+ session.layerStack.commit { $0 + [layer] }
614
+ selectLayer(layer.id)
615
+ setStickersMode(false)
616
+ case .photoOverlay:
617
+ guard let session = photoSession else { return }
618
+ var layer = PhotoLayer(type: .overlay)
619
+ layer.overlayUri = uri
620
+ layer.overlayAspectRatio = aspectRatio
621
+ layer.scale = 0.7
622
+ session.layerStack.commit { $0 + [layer] }
623
+ selectLayer(layer.id)
624
+ case .videoSticker:
625
+ guard let session = videoSession else { return }
626
+ var layer = PhotoLayer(type: .sticker)
627
+ layer.stickerUri = uri
628
+ session.layerStack.commit { $0 + [layer] }
629
+ onVideoLayerStackChanged()
630
+ selectVideoLayer(layer.id, session: session)
631
+ case .videoOverlay:
632
+ guard let session = videoSession else { return }
633
+ var layer = PhotoLayer(type: .overlay)
634
+ layer.overlayUri = uri
635
+ layer.overlayAspectRatio = aspectRatio
636
+ layer.scale = 0.7
637
+ session.layerStack.commit { $0 + [layer] }
638
+ onVideoLayerStackChanged()
639
+ selectVideoLayer(layer.id, session: session)
640
+ }
641
+ }
642
+
643
+ private func refreshPhotoToolSelection() {
644
+ let textColor = color("textColor") ?? DesignTokens.onSurface
645
+ photoToolNodes.forEach { key, node in
646
+ let selected: Bool
647
+ switch key {
648
+ case "crop": selected = cropMode
649
+ case "adjust", "filters": selected = filtersMode
650
+ case "stickers": selected = stickersMode
651
+ default: selected = false
652
+ }
653
+ setToolNodeSelected(node, selected: selected, textColor: textColor)
654
+ if selected { scrollToolNodeIntoView(node) }
655
+ }
656
+ }
657
+
658
+ /// Keeps the active tool-rail node fully visible, never partially clipped at the scroll edge.
659
+ private func scrollToolNodeIntoView(_ node: ToolNode) {
660
+ guard let scroll = photoToolScroll else { return }
661
+ DispatchQueue.main.async {
662
+ let frame = node.root.convert(node.root.bounds, to: scroll)
663
+ scroll.scrollRectToVisible(frame.insetBy(dx: -DesignTokens.spaceSm, dy: 0), animated: true)
664
+ }
665
+ }
666
+
667
+ private func selectLayer(_ id: String?) {
668
+ selectedLayerID = id
669
+ layerOverlay?.selectedLayerID = id
670
+ if let session = photoSession {
671
+ layerOverlay?.layers = session.layerStack.layers
672
+ schedulePhotoPreviewRender()
673
+ }
674
+ setLayerToolBarVisible(id != nil)
675
+ }
676
+
677
+ private func setLayerToolBarVisible(_ visible: Bool) {
678
+ layerToolBar?.isHidden = !visible
679
+ mainToolBar?.isHidden = visible
680
+ if visible {
681
+ // TEXT layers show only Opacity/Colors/Duplicate/Delete, and STICKER layers show only
682
+ // Opacity/Duplicate/Delete (no Colors) — Scale/Rotate and the lock/visibility/reorder
683
+ // icons don't apply to either and are hidden per the reduced action list.
684
+ let isText = currentSelectedLayer()?.type == .text
685
+ let isSticker = currentSelectedLayer()?.type == .sticker
686
+ let reducedControls = isText || isSticker
687
+ layerPropertyButtons["scale"]?.isHidden = reducedControls
688
+ layerPropertyButtons["rotation"]?.isHidden = reducedControls
689
+ layerPropertyButtons["color"]?.isHidden = !isText
690
+ ["lock", "visibility", "front", "back"].forEach { key in
691
+ layerIconButtons[key]?.isHidden = reducedControls
692
+ }
693
+ if reducedControls, ["scale", "rotation"].contains(activeLayerPropertyKey) {
694
+ activeLayerPropertyKey = "opacity"
695
+ } else if !isText, activeLayerPropertyKey == "color" {
696
+ activeLayerPropertyKey = reducedControls ? "opacity" : "scale"
697
+ }
698
+ layerPropertySlider?.isHidden = activeLayerPropertyKey == "color"
699
+ layerColorSwatchRow?.isHidden = activeLayerPropertyKey != "color"
700
+ if activeLayerPropertyKey != "color" { syncLayerPropertySlider() }
701
+ let textColor = color("textColor") ?? DesignTokens.onSurface
702
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
703
+ refreshSelection(layerPropertyButtons, activeKey: activeLayerPropertyKey, textColor: textColor, primaryColor: primaryColor)
704
+ } else {
705
+ layerPropertySlider?.isHidden = true
706
+ layerColorSwatchRow?.isHidden = true
707
+ }
708
+ }
709
+
710
+ private func currentSelectedLayer() -> PhotoLayer? {
711
+ guard let session = photoSession else { return nil }
712
+ return session.layerStack.layers.first { $0.id == selectedLayerID }
713
+ }
714
+
715
+ private func syncLayerPropertySlider() {
716
+ guard let slider = layerPropertySlider, let layer = currentSelectedLayer() else { return }
717
+ let (min, max, value): (CGFloat, CGFloat, CGFloat)
718
+ switch activeLayerPropertyKey {
719
+ case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
720
+ case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
721
+ default: (min, max, value) = (20, 800, layer.scale * 100)
722
+ }
723
+ slider.minimumValue = Float(min)
724
+ slider.maximumValue = Float(max)
725
+ slider.value = Float(value)
726
+ }
727
+
728
+ @objc private func layerPropertyChanged(_ slider: UISlider) {
729
+ guard let session = photoSession, let id = selectedLayerID else { return }
730
+ let value = CGFloat(slider.value)
731
+ let key = activeLayerPropertyKey
732
+ session.layerStack.updateLive { list in
733
+ list.map { layer in
734
+ guard layer.id == id else { return layer }
735
+ var updated = layer
736
+ switch key {
737
+ case "rotation": updated.rotationDegrees = value
738
+ case "opacity": updated.opacity = value / 100
739
+ default: updated.scale = value / 100
740
+ }
741
+ return updated
742
+ }
743
+ }
744
+ layerOverlay?.layers = session.layerStack.layers
745
+ schedulePhotoPreviewRender()
746
+ }
747
+
748
+ private func makeLayerToolBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
749
+ // "color" only makes sense for TEXT layers; its chip is hidden/shown per-selection in `setLayerToolBarVisible`.
750
+ let properties: [(String, String)] = [("scale", "Scale"), ("rotation", "Rotate"), ("opacity", "Opacity"), ("color", "Colors")]
751
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
752
+ let scroll = UIScrollView()
753
+ let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
754
+ properties.forEach { key, label in
755
+ let chip = button(label, color: textColor) { [weak self] in
756
+ self?.activeLayerPropertyKey = key
757
+ self?.layerPropertySlider?.isHidden = key == "color"
758
+ self?.layerColorSwatchRow?.isHidden = key != "color"
759
+ if key != "color" { self?.syncLayerPropertySlider() }
760
+ if let self { self.refreshSelection(self.layerPropertyButtons, activeKey: self.activeLayerPropertyKey, textColor: textColor, primaryColor: primaryColor) }
761
+ }
762
+ layerPropertyButtons[key] = chip
763
+ chipStack.addArrangedSubview(chip)
764
+ }
765
+ chipStack.addArrangedSubview(button("Duplicate", color: textColor) { [weak self] in
766
+ guard let self, let original = self.currentSelectedLayer() else { return }
767
+ var copy = PhotoLayer(id: UUID().uuidString, type: original.type)
768
+ copy.x = min(max(original.x + 0.04, 0), 1)
769
+ copy.y = min(max(original.y + 0.04, 0), 1)
770
+ copy.scale = original.scale
771
+ copy.rotationDegrees = original.rotationDegrees
772
+ copy.opacity = original.opacity
773
+ copy.text = original.text
774
+ copy.textColor = original.textColor
775
+ copy.fontSize = original.fontSize
776
+ copy.fontFamily = original.fontFamily
777
+ copy.stickerId = original.stickerId
778
+ copy.stickerUri = original.stickerUri
779
+ copy.overlayUri = original.overlayUri
780
+ copy.overlayAspectRatio = original.overlayAspectRatio
781
+ session.layerStack.commit { $0 + [copy] }
782
+ self.selectLayer(copy.id)
783
+ })
784
+ let lockButton = button("Lock", color: textColor) { [weak self] in
785
+ guard let self, let id = self.selectedLayerID else { return }
786
+ session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.locked.toggle(); return l }() : $0 } }
787
+ self.onLayerStackChanged()
788
+ }
789
+ layerIconButtons["lock"] = lockButton
790
+ chipStack.addArrangedSubview(lockButton)
791
+ let visibilityButton = button("Hide", color: textColor) { [weak self] in
792
+ guard let self, let id = self.selectedLayerID else { return }
793
+ session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.visible.toggle(); return l }() : $0 } }
794
+ self.onLayerStackChanged()
795
+ }
796
+ layerIconButtons["visibility"] = visibilityButton
797
+ chipStack.addArrangedSubview(visibilityButton)
798
+ let frontButton = button("Front", color: textColor) { [weak self] in
799
+ guard let self, let id = self.selectedLayerID else { return }
800
+ session.layerStack.commit { list in list.filter { $0.id != id } + list.filter { $0.id == id } }
801
+ self.onLayerStackChanged()
802
+ }
803
+ layerIconButtons["front"] = frontButton
804
+ chipStack.addArrangedSubview(frontButton)
805
+ let backButton = button("Back", color: textColor) { [weak self] in
806
+ guard let self, let id = self.selectedLayerID else { return }
807
+ session.layerStack.commit { list in list.filter { $0.id == id } + list.filter { $0.id != id } }
808
+ self.onLayerStackChanged()
809
+ }
810
+ layerIconButtons["back"] = backButton
811
+ chipStack.addArrangedSubview(backButton)
812
+ chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
813
+ guard let self, let id = self.selectedLayerID else { return }
814
+ session.layerStack.commit { list in list.filter { $0.id != id } }
815
+ self.selectLayer(nil)
816
+ self.onLayerStackChanged()
817
+ })
818
+ scroll.addSubview(chipStack)
819
+ chipStack.translatesAutoresizingMaskIntoConstraints = false
820
+ NSLayoutConstraint.activate([
821
+ chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
822
+ chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
823
+ chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
824
+ chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
825
+ chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
826
+ ])
827
+ bar.addArrangedSubview(scroll)
828
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
829
+ bar.backgroundColor = toolbarColor
830
+ bar.isLayoutMarginsRelativeArrangement = true
831
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
832
+ return bar
833
+ }
834
+
835
+ /// Circular color swatch, matching the Android drawing_brush_studio mockup's palette dots.
836
+ private func createColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIView {
837
+ let outer = UIButton(type: .custom)
838
+ outer.backgroundColor = DesignTokens.surfaceContainerHigh
839
+ outer.layer.cornerRadius = 16
840
+ outer.widthAnchor.constraint(equalToConstant: 32).isActive = true
841
+ outer.heightAnchor.constraint(equalToConstant: 32).isActive = true
842
+ let inner = UIView()
843
+ inner.backgroundColor = color
844
+ inner.layer.cornerRadius = 12
845
+ inner.isUserInteractionEnabled = false
846
+ inner.translatesAutoresizingMaskIntoConstraints = false
847
+ outer.addSubview(inner)
848
+ NSLayoutConstraint.activate([
849
+ inner.centerXAnchor.constraint(equalTo: outer.centerXAnchor),
850
+ inner.centerYAnchor.constraint(equalTo: outer.centerYAnchor),
851
+ inner.widthAnchor.constraint(equalToConstant: 24),
852
+ inner.heightAnchor.constraint(equalToConstant: 24),
853
+ ])
854
+ outer.addAction(UIAction { _ in onTap() }, for: .touchUpInside)
855
+ return outer
856
+ }
857
+
858
+ /// Text color swatch row, shown when a TEXT layer's "Colors" property is active.
859
+ private func makeTextColorSwatchRow(session: PhotoEditSession) -> UIView {
860
+ let toolbarColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
861
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
862
+ let colors: [UIColor] = [
863
+ .white, .black, .red, UIColor(red: 255 / 255, green: 214 / 255, blue: 51 / 255, alpha: 1),
864
+ UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1), UIColor(red: 0, green: 145 / 255, blue: 255 / 255, alpha: 1),
865
+ DesignTokens.primary, DesignTokens.tertiary,
866
+ ]
867
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
868
+ let scroll = UIScrollView()
869
+ let swatchStack = UIStackView(); swatchStack.axis = .horizontal; swatchStack.spacing = 8
870
+ colors.forEach { swatchColor in
871
+ swatchStack.addArrangedSubview(createColorSwatch(color: swatchColor) { [weak self] in
872
+ guard let self, let id = self.selectedLayerID else { return }
873
+ session.layerStack.commit { list in list.map { $0.id == id ? { var l = $0; l.textColor = swatchColor; return l }() : $0 } }
874
+ self.onLayerStackChanged()
875
+ })
876
+ }
877
+ scroll.addSubview(swatchStack)
878
+ swatchStack.translatesAutoresizingMaskIntoConstraints = false
879
+ NSLayoutConstraint.activate([
880
+ swatchStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
881
+ swatchStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
882
+ swatchStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
883
+ swatchStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
884
+ swatchStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
885
+ ])
886
+ bar.addArrangedSubview(scroll)
887
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectLayer(nil) })
888
+ bar.backgroundColor = toolbarColor
889
+ bar.isLayoutMarginsRelativeArrangement = true
890
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
891
+ return bar
892
+ }
893
+
894
+ /// Refreshes the preview + selection + undo/redo enabled state after any layer-stack change.
895
+ private func onLayerStackChanged() {
896
+ guard let session = photoSession else { return }
897
+ if let id = selectedLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
898
+ selectedLayerID = nil
899
+ }
900
+ layerOverlay?.layers = session.layerStack.layers
901
+ layerOverlay?.selectedLayerID = selectedLayerID
902
+ schedulePhotoPreviewRender()
903
+ undoButton?.isEnabled = session.layerStack.canUndo
904
+ redoButton?.isEnabled = session.layerStack.canRedo
905
+ undoButton?.alpha = session.layerStack.canUndo ? 1 : 0.4
906
+ redoButton?.alpha = session.layerStack.canRedo ? 1 : 0.4
907
+ if selectedLayerID == nil, layerToolBar?.isHidden == false { setLayerToolBarVisible(false) }
908
+ }
909
+
910
+ private func setFiltersMode(_ enabled: Bool, session: PhotoEditSession) {
911
+ filtersMode = enabled
912
+ filtersSubBar?.isHidden = !enabled
913
+ adjustmentSlider?.isHidden = !enabled
914
+ mainToolBar?.isHidden = enabled
915
+ layerOverlay?.isHidden = enabled
916
+ if enabled { syncAdjustmentSlider(session: session) }
917
+ refreshPhotoToolSelection()
918
+ refreshFiltersSelection(session: session)
919
+ }
920
+
921
+ private func refreshFiltersSelection(session: PhotoEditSession) {
922
+ let textColor = color("textColor") ?? DesignTokens.onSurface
923
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
924
+ refreshSelection(filterAdjustmentButtons, activeKey: activeAdjustmentKey, textColor: textColor, primaryColor: primaryColor)
925
+ refreshSelection(filterPresetButtons, activeKey: session.adjustments.filterPreset, textColor: textColor, primaryColor: primaryColor)
926
+ }
927
+
928
+ private func syncAdjustmentSlider(session: PhotoEditSession) {
929
+ guard let slider = adjustmentSlider else { return }
930
+ let (min, max) = PhotoAdjustments.range(for: activeAdjustmentKey)
931
+ slider.minimumValue = Float(min)
932
+ slider.maximumValue = Float(max)
933
+ slider.value = Float(session.adjustments.value(activeAdjustmentKey))
934
+ }
935
+
936
+ @objc private func adjustmentChanged(_ slider: UISlider) {
937
+ guard let session = photoSession, let imageView = photoImageView else { return }
938
+ let key = activeAdjustmentKey
939
+ session.updateAdjustments { $0.setValue(key, Double(slider.value)) }
940
+ schedulePhotoPreviewRender()
941
+ }
942
+
943
+ private func makeFiltersSubBar(
944
+ session: PhotoEditSession,
945
+ imageView: ZoomableImageView,
946
+ textColor: UIColor,
947
+ primaryColor: UIColor,
948
+ toolbarColor: UIColor
949
+ ) -> UIView {
950
+ let adjustmentChips: [(String, String)] = [
951
+ ("brightness", "Brightness"), ("contrast", "Contrast"), ("saturation", "Saturation"),
952
+ ("exposure", "Exposure"), ("temperature", "Temp"), ("blurRadius", "Blur"),
953
+ ]
954
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
955
+ let scroll = UIScrollView()
956
+ let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
957
+ adjustmentChips.forEach { key, label in
958
+ let chip = button(label, color: textColor) { [weak self] in
959
+ self?.activeAdjustmentKey = key
960
+ self?.syncAdjustmentSlider(session: session)
961
+ if let self { self.refreshFiltersSelection(session: session) }
962
+ }
963
+ filterAdjustmentButtons[key] = chip
964
+ chipStack.addArrangedSubview(chip)
965
+ }
966
+ chipStack.addArrangedSubview(button("Mirror", color: textColor) { [weak self] in
967
+ session.updateAdjustments { $0.mirror.toggle() }
968
+ self?.schedulePhotoPreviewRender()
969
+ })
970
+ PhotoFilterPresets.presetIDs.forEach { id in
971
+ let chip = button(PhotoFilterPresets.label(for: id), color: textColor) { [weak self] in
972
+ session.updateAdjustments { $0.filterPreset = $0.filterPreset == id ? nil : id }
973
+ self?.activeAdjustmentKey = "filterStrength"
974
+ self?.syncAdjustmentSlider(session: session)
975
+ self?.schedulePhotoPreviewRender()
976
+ if let self { self.refreshFiltersSelection(session: session) }
977
+ }
978
+ filterPresetButtons[id] = chip
979
+ chipStack.addArrangedSubview(chip)
980
+ }
981
+ chipStack.addArrangedSubview(button("Reset", color: textColor) { [weak self] in
982
+ session.updateAdjustments { $0 = PhotoAdjustments() }
983
+ self?.syncAdjustmentSlider(session: session)
984
+ self?.schedulePhotoPreviewRender()
985
+ if let self { self.refreshFiltersSelection(session: session) }
986
+ })
987
+ scroll.addSubview(chipStack)
988
+ chipStack.translatesAutoresizingMaskIntoConstraints = false
989
+ NSLayoutConstraint.activate([
990
+ chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
991
+ chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
992
+ chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
993
+ chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
994
+ chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
995
+ ])
996
+ bar.addArrangedSubview(scroll)
997
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.setFiltersMode(false, session: session) })
998
+ bar.backgroundColor = toolbarColor
999
+ bar.isLayoutMarginsRelativeArrangement = true
1000
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1001
+ return bar
1002
+ }
1003
+
1004
+ private func setCropMode(_ enabled: Bool) {
1005
+ guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1006
+ cropMode = enabled
1007
+ imageView.panZoomEnabled = !enabled
1008
+ overlay.isHidden = !enabled
1009
+ straightenSlider?.isHidden = !enabled
1010
+ cropSubBar?.isHidden = !enabled
1011
+ mainToolBar?.isHidden = enabled
1012
+ layerOverlay?.isHidden = enabled
1013
+ if enabled {
1014
+ imageView.resetToFit()
1015
+ overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: session.state.isIdentity)
1016
+ }
1017
+ refreshPhotoToolSelection()
1018
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1019
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1020
+ refreshSelection(cropAspectButtons, activeKey: aspectRatioLabel(session.state.aspectRatio), textColor: textColor, primaryColor: primaryColor)
1021
+ }
1022
+
1023
+ /// Maps an aspect ratio back to its preset label (matching the presets defined in `makeCropSubBar`/`makeVideoAspectSubBar`) so the correct chip can be highlighted.
1024
+ private func aspectRatioLabel(_ ratio: CGFloat?) -> String {
1025
+ guard let ratio else { return "Free" }
1026
+ let knownRatios: [(String, CGFloat)] = [("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
1027
+ return knownRatios.first(where: { $0.1 == ratio })?.0 ?? "Free"
1028
+ }
1029
+
1030
+ private func resetPhotoEdits() {
1031
+ guard let imageView = photoImageView, let overlay = cropOverlay, let session = photoSession else { return }
1032
+ session.update { $0.reset() }
1033
+ overlay.setAspectRatio(nil)
1034
+ straightenSlider?.value = 0
1035
+ schedulePhotoPreviewRender()
1036
+ imageView.resetToFit()
1037
+ overlay.setImageBounds(imageView.currentImageBounds(), resetCrop: true)
1038
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1039
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1040
+ refreshSelection(cropAspectButtons, activeKey: "Free", textColor: textColor, primaryColor: primaryColor)
1041
+ }
1042
+
1043
+ @objc private func straightenChanged(_ slider: UISlider) {
1044
+ guard let session = photoSession, let imageView = photoImageView else { return }
1045
+ session.update { $0.setStraighten(CGFloat(slider.value)) }
1046
+ schedulePhotoPreviewRender()
1047
+ }
1048
+
1049
+ private func exportPhoto() {
1050
+ guard let session = photoSession else {
1051
+ completion?(.failure(code: "E_INTERNAL", message: "Nothing to export."))
1052
+ return
1053
+ }
1054
+ progressOverlay?.isHidden = false
1055
+ let state = session.state
1056
+ let adjustments = session.adjustments
1057
+ let layers = session.layerStack.layers
1058
+ var options = exportOptions
1059
+ if let selectedPhotoExportFormat { options["imageFormat"] = selectedPhotoExportFormat }
1060
+ let sourceUri = uri
1061
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
1062
+ do {
1063
+ let result = try PhotoExporter.export(sourceUri: sourceUri, transform: state, adjustments: adjustments, layers: layers, exportOptions: options)
1064
+ DispatchQueue.main.async {
1065
+ self?.completion?(.success(uri: result.uri, mimeType: result.mimeType, width: result.width, height: result.height, fileSize: result.fileSize))
1066
+ }
1067
+ } catch let error as PhotoExportError {
1068
+ DispatchQueue.main.async { self?.completion?(.failure(code: error.code, message: error.message)) }
1069
+ } catch {
1070
+ DispatchQueue.main.async { self?.completion?(.failure(code: "E_EXPORT_FAILED", message: "Unable to export the photo.")) }
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ // MARK: - Video editor shell (Milestone 1 only — trim/crop/etc. are Milestone 5+)
1076
+
1077
+ private func buildVideoEditor() {
1078
+ let backgroundColor = color("backgroundColor") ?? DesignTokens.surfaceContainerLowest
1079
+ let toolbarColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
1080
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1081
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1082
+ view.backgroundColor = backgroundColor
1083
+ setNeedsStatusBarAppearanceUpdate()
1084
+
1085
+ let session = VideoEditSession(sourceUri: uri)
1086
+ videoSession = session
1087
+
1088
+ let header = makeHeader(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1089
+ let preview = UIView(); preview.backgroundColor = backgroundColor
1090
+
1091
+ let playerController = AVPlayerViewController()
1092
+ playerController.player = session.player
1093
+ playerController.showsPlaybackControls = false
1094
+ addChild(playerController)
1095
+ preview.addSubview(playerController.view)
1096
+ playerController.view.translatesAutoresizingMaskIntoConstraints = false
1097
+ NSLayoutConstraint.activate([
1098
+ playerController.view.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
1099
+ playerController.view.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
1100
+ playerController.view.topAnchor.constraint(equalTo: preview.topAnchor),
1101
+ playerController.view.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1102
+ ])
1103
+ playerController.didMove(toParent: self)
1104
+
1105
+ let overlayView = UIImageView()
1106
+ overlayView.contentMode = .scaleAspectFit
1107
+ videoOverlayImageView = overlayView
1108
+ preview.addSubview(overlayView)
1109
+ overlayView.translatesAutoresizingMaskIntoConstraints = false
1110
+ NSLayoutConstraint.activate([
1111
+ overlayView.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
1112
+ overlayView.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
1113
+ overlayView.topAnchor.constraint(equalTo: preview.topAnchor),
1114
+ overlayView.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1115
+ ])
1116
+
1117
+ let videoLayers = LayerOverlayView()
1118
+ videoLayerOverlay = videoLayers
1119
+ preview.addSubview(videoLayers)
1120
+ videoLayers.translatesAutoresizingMaskIntoConstraints = false
1121
+ NSLayoutConstraint.activate([
1122
+ videoLayers.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
1123
+ videoLayers.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
1124
+ videoLayers.topAnchor.constraint(equalTo: preview.topAnchor),
1125
+ videoLayers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
1126
+ ])
1127
+ videoLayers.onLayerTapped = { [weak self] id in self?.selectVideoLayer(id, session: session) }
1128
+ videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
1129
+ guard let self else { return }
1130
+ if self.videoDragStartSnapshot == nil { self.videoDragStartSnapshot = session.layerStack.layers }
1131
+ session.layerStack.updateLive { list in list.map { $0.id == id ? {
1132
+ var layer = $0; layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation; return layer
1133
+ }() : $0 } }
1134
+ videoLayers.layers = session.layerStack.layers
1135
+ self.refreshVideoOverlayPreview(session: session)
1136
+ }
1137
+ videoLayers.onLayerTransformEnded = { [weak self] _ in
1138
+ guard let self else { return }
1139
+ if let snapshot = self.videoDragStartSnapshot { session.layerStack.commitSnapshot(snapshot) }
1140
+ self.videoDragStartSnapshot = nil
1141
+ self.onVideoLayerStackChanged()
1142
+ }
1143
+
1144
+ let playPause = button("Play", color: textColor) { [weak self] in
1145
+ guard let self, let session = self.videoSession else { return }
1146
+ // `.rate = x` (rather than `.play()`) so playback always resumes at the currently selected speed.
1147
+ if session.player.timeControlStatus == .playing { session.player.pause() } else { session.player.rate = session.state.speed }
1148
+ }
1149
+ playPauseButton = playPause
1150
+ let time = UILabel()
1151
+ time.text = "0:00 / 0:00"
1152
+ time.textColor = textColor
1153
+ time.font = .systemFont(ofSize: 12)
1154
+ timeLabel = time
1155
+ let transport = UIStackView(arrangedSubviews: [playPause, time])
1156
+ transport.axis = .horizontal
1157
+ transport.alignment = .center
1158
+ transport.spacing = 8
1159
+ transport.backgroundColor = toolbarColor
1160
+ transport.isLayoutMarginsRelativeArrangement = true
1161
+ transport.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
1162
+
1163
+ let position = UISlider()
1164
+ position.minimumValue = 0
1165
+ position.maximumValue = 1000
1166
+ position.addTarget(self, action: #selector(positionSliderChanged(_:)), for: .valueChanged)
1167
+ position.addTarget(self, action: #selector(positionSliderTouchDown(_:)), for: .touchDown)
1168
+ position.addTarget(self, action: #selector(positionSliderTouchUp(_:)), for: [.touchUpInside, .touchUpOutside])
1169
+ positionSlider = position
1170
+
1171
+ let trim = TrimRangeView()
1172
+ // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
1173
+ // own `originalDurationMs`, not the composed multi-clip timeline.
1174
+ trim.onRangeChanged = { [weak self] start, end in
1175
+ guard let self, let session = self.videoSession, let clip = session.clips[safe: self.selectedClipIndex] else { return }
1176
+ let newStart = Int64(start * CGFloat(clip.originalDurationMs))
1177
+ let newEnd = Int64(end * CGFloat(clip.originalDurationMs))
1178
+ let index = self.selectedClipIndex
1179
+ session.updateClips { clips in
1180
+ clips.enumerated().map { i, c in i == index ? { var c = c; c.trimStartMs = newStart; c.trimEndMs = newEnd; return c }() : c }
1181
+ }
1182
+ self.updateTimeLabel()
1183
+ self.refreshClipStrip(session: session)
1184
+ }
1185
+ trimRangeView = trim
1186
+
1187
+ let strip = UIStackView()
1188
+ strip.axis = .horizontal
1189
+ strip.spacing = 4
1190
+ strip.isLayoutMarginsRelativeArrangement = true
1191
+ strip.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
1192
+ clipStripBar = strip
1193
+ let stripScroll = UIScrollView()
1194
+ stripScroll.addSubview(strip)
1195
+ stripScroll.showsHorizontalScrollIndicator = false
1196
+ strip.translatesAutoresizingMaskIntoConstraints = false
1197
+ NSLayoutConstraint.activate([
1198
+ strip.leadingAnchor.constraint(equalTo: stripScroll.contentLayoutGuide.leadingAnchor),
1199
+ strip.trailingAnchor.constraint(equalTo: stripScroll.contentLayoutGuide.trailingAnchor),
1200
+ strip.topAnchor.constraint(equalTo: stripScroll.contentLayoutGuide.topAnchor),
1201
+ strip.bottomAnchor.constraint(equalTo: stripScroll.contentLayoutGuide.bottomAnchor),
1202
+ strip.heightAnchor.constraint(equalTo: stripScroll.frameLayoutGuide.heightAnchor),
1203
+ ])
1204
+ clipStripScroll = stripScroll
1205
+
1206
+ session.onClipsReady = { [weak self] in
1207
+ guard let self else { return }
1208
+ DispatchQueue.main.async {
1209
+ self.selectedClipIndex = 0
1210
+ self.updateTimeLabel()
1211
+ self.bindTrimViewToSelectedClip(session: session)
1212
+ self.refreshClipStrip(session: session)
1213
+ }
1214
+ }
1215
+
1216
+ let aspectBar = makeVideoAspectSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1217
+ aspectBar.isHidden = true
1218
+ videoAspectSubBar = aspectBar
1219
+
1220
+ let layerSlider = UISlider()
1221
+ layerSlider.isHidden = true
1222
+ layerSlider.addTarget(self, action: #selector(videoLayerPropertyChanged(_:)), for: .valueChanged)
1223
+ videoLayerPropertySlider = layerSlider
1224
+
1225
+ let layerBar = makeVideoLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
1226
+ layerBar.isHidden = true
1227
+ videoLayerToolBar = layerBar
1228
+
1229
+ let toolbar = makeVideoToolBar(session: session, playerController: playerController, textColor: textColor, toolbarColor: toolbarColor)
1230
+ videoToolBar = toolbar
1231
+
1232
+ let root = UIStackView(arrangedSubviews: [header, preview, transport, position, trim, stripScroll, aspectBar, layerSlider, layerBar, toolbar])
1233
+ root.axis = .vertical
1234
+ root.translatesAutoresizingMaskIntoConstraints = false
1235
+ view.addSubview(root)
1236
+ NSLayoutConstraint.activate([
1237
+ root.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
1238
+ root.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
1239
+ root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1240
+ root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
1241
+ header.heightAnchor.constraint(equalToConstant: 60),
1242
+ transport.heightAnchor.constraint(equalToConstant: 40),
1243
+ trim.heightAnchor.constraint(equalToConstant: 48),
1244
+ stripScroll.heightAnchor.constraint(equalToConstant: 40),
1245
+ aspectBar.heightAnchor.constraint(equalToConstant: 56),
1246
+ layerBar.heightAnchor.constraint(equalToConstant: 56),
1247
+ toolbar.heightAnchor.constraint(equalToConstant: 72),
1248
+ ])
1249
+
1250
+ let progress = UIView()
1251
+ progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
1252
+ progress.isHidden = true
1253
+ let column = UIStackView()
1254
+ column.axis = .vertical
1255
+ column.alignment = .center
1256
+ column.spacing = 12
1257
+ let spinner = UIActivityIndicatorView(style: .large)
1258
+ spinner.color = .white
1259
+ spinner.startAnimating()
1260
+ column.addArrangedSubview(spinner)
1261
+ column.addArrangedSubview(button("Cancel export", color: .white) { [weak self] in self?.cancelVideoExport() })
1262
+ progress.addSubview(column)
1263
+ column.translatesAutoresizingMaskIntoConstraints = false
1264
+ NSLayoutConstraint.activate([column.centerXAnchor.constraint(equalTo: progress.centerXAnchor), column.centerYAnchor.constraint(equalTo: progress.centerYAnchor)])
1265
+ videoExportProgressOverlay = progress
1266
+ view.addSubview(progress)
1267
+ progress.translatesAutoresizingMaskIntoConstraints = false
1268
+ NSLayoutConstraint.activate([
1269
+ progress.leadingAnchor.constraint(equalTo: view.leadingAnchor),
1270
+ progress.trailingAnchor.constraint(equalTo: view.trailingAnchor),
1271
+ progress.topAnchor.constraint(equalTo: view.topAnchor),
1272
+ progress.bottomAnchor.constraint(equalTo: view.bottomAnchor),
1273
+ ])
1274
+
1275
+ startPositionPolling()
1276
+ }
1277
+
1278
+ private func startPositionPolling() {
1279
+ positionPollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
1280
+ guard let self, let session = self.videoSession else { return }
1281
+ if !self.seekingPosition, session.durationMs > 0 {
1282
+ let currentMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1283
+ self.positionSlider?.value = Float(min(max(Double(currentMs) / Double(session.durationMs), 0), 1)) * 1000
1284
+ self.updateTimeLabel()
1285
+ }
1286
+ self.playPauseButton?.setTitle(session.player.timeControlStatus == .playing ? "Pause" : "Play", for: .normal)
1287
+ self.refreshVideoOverlayPreview(session: session)
1288
+ }
1289
+ }
1290
+
1291
+ private func updateTimeLabel() {
1292
+ guard let session = videoSession else { return }
1293
+ let currentMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1294
+ timeLabel?.text = "\(formatMs(currentMs)) / \(formatMs(session.durationMs))"
1295
+ }
1296
+
1297
+ private func formatMs(_ ms: Int64) -> String {
1298
+ let totalSeconds = max(ms / 1000, 0)
1299
+ return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
1300
+ }
1301
+
1302
+ /// Renders whichever overlays are active at the current playhead onto a
1303
+ /// transparent image sized to the video's own natural size. Reuses
1304
+ /// `PhotoLayerRenderer` unchanged — overlays are just `PhotoLayer`s with a time range.
1305
+ private func refreshVideoOverlayPreview(session: VideoEditSession) {
1306
+ let positionMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1307
+ let activeLayers = session.layerStack.layers.filter { $0.isActive(atMs: positionMs, durationMs: session.durationMs) }
1308
+ if let overlayView = videoOverlayImageView {
1309
+ videoLayerOverlay?.layers = activeLayers
1310
+ videoLayerOverlay?.setImageBounds(computeVideoLetterboxBounds(session: session, in: overlayView))
1311
+ }
1312
+ guard !activeLayers.isEmpty else {
1313
+ videoOverlayImageView?.image = nil
1314
+ videoOverlayRenderKey = nil
1315
+ return
1316
+ }
1317
+ guard let clip = session.clips.first else {
1318
+ videoOverlayImageView?.image = nil
1319
+ return
1320
+ }
1321
+ if videoNaturalSize == nil,
1322
+ let track = session.asset(for: clip).tracks(withMediaType: .video).first {
1323
+ let naturalSize = track.naturalSize.applying(track.preferredTransform)
1324
+ videoNaturalSize = CGSize(width: max(1, abs(naturalSize.width)), height: max(1, abs(naturalSize.height)))
1325
+ }
1326
+ guard let naturalSize = videoNaturalSize else { return }
1327
+ let previewScale = min(1, 1280 / max(naturalSize.width, naturalSize.height))
1328
+ let size = CGSize(width: max(2, floor(naturalSize.width * previewScale)), height: max(2, floor(naturalSize.height * previewScale)))
1329
+ let renderKey = "\(session.layerStack.revision):\(Int(size.width)):\(Int(size.height)):\(activeLayers.map(\.id).joined(separator: ","))"
1330
+ guard renderKey != videoOverlayRenderKey else { return }
1331
+ let renderer = PhotoEditSession.pixelRenderer(size: size)
1332
+ let transparent = renderer.image { _ in }
1333
+ videoOverlayImageView?.image = PhotoLayerRenderer.render(transparent, layers: activeLayers) { [weak self] uri in self?.resolveVideoImageLayer(uri) }
1334
+ videoOverlayRenderKey = renderKey
1335
+ }
1336
+
1337
+ /// One-shot resolver for sticker-uri and overlay-uri layers used by the video overlay preview.
1338
+ private func resolveVideoImageLayer(_ uri: String) -> UIImage? {
1339
+ guard let path = SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_video_layer") else { return nil }
1340
+ return UIImage(contentsOfFile: path)
1341
+ }
1342
+
1343
+ /// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
1344
+ private func computeVideoLetterboxBounds(session: VideoEditSession, in view: UIView) -> CGRect {
1345
+ guard let clip = session.clips.first, let track = session.asset(for: clip).tracks(withMediaType: .video).first else { return view.bounds }
1346
+ let naturalSize = track.naturalSize.applying(track.preferredTransform)
1347
+ let videoWidth = abs(naturalSize.width)
1348
+ let videoHeight = abs(naturalSize.height)
1349
+ let viewWidth = view.bounds.width
1350
+ let viewHeight = view.bounds.height
1351
+ guard videoWidth > 0, videoHeight > 0, viewWidth > 0, viewHeight > 0 else { return view.bounds }
1352
+ let scale = min(viewWidth / videoWidth, viewHeight / videoHeight)
1353
+ let fittedWidth = videoWidth * scale
1354
+ let fittedHeight = videoHeight * scale
1355
+ let x = (viewWidth - fittedWidth) / 2
1356
+ let y = (viewHeight - fittedHeight) / 2
1357
+ return CGRect(x: x, y: y, width: fittedWidth, height: fittedHeight)
1358
+ }
1359
+
1360
+ /// Mirrors `onLayerStackChanged` for the video overlay stack (Milestone 6).
1361
+ private func onVideoLayerStackChanged() {
1362
+ guard let session = videoSession else { return }
1363
+ if let id = selectedVideoLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
1364
+ selectedVideoLayerID = nil
1365
+ }
1366
+ videoLayerOverlay?.selectedLayerID = selectedVideoLayerID
1367
+ refreshVideoOverlayPreview(session: session)
1368
+ undoButton?.isEnabled = session.layerStack.canUndo
1369
+ redoButton?.isEnabled = session.layerStack.canRedo
1370
+ undoButton?.alpha = session.layerStack.canUndo ? 1 : 0.4
1371
+ redoButton?.alpha = session.layerStack.canRedo ? 1 : 0.4
1372
+ if selectedVideoLayerID == nil, videoLayerToolBar?.isHidden == false { setVideoLayerToolBarVisible(false, session: session) }
1373
+ }
1374
+
1375
+ private func selectVideoLayer(_ id: String?, session: VideoEditSession) {
1376
+ selectedVideoLayerID = id
1377
+ videoLayerOverlay?.selectedLayerID = id
1378
+ setVideoLayerToolBarVisible(id != nil, session: session)
1379
+ }
1380
+
1381
+ private func setVideoLayerToolBarVisible(_ visible: Bool, session: VideoEditSession) {
1382
+ videoLayerToolBar?.isHidden = !visible
1383
+ videoLayerPropertySlider?.isHidden = !visible
1384
+ videoToolBar?.isHidden = visible
1385
+ if visible { syncVideoLayerPropertySlider(session: session) }
1386
+ }
1387
+
1388
+ private func currentSelectedVideoLayer(session: VideoEditSession) -> PhotoLayer? {
1389
+ session.layerStack.layers.first { $0.id == selectedVideoLayerID }
1390
+ }
1391
+
1392
+ private func syncVideoLayerPropertySlider(session: VideoEditSession) {
1393
+ guard let slider = videoLayerPropertySlider, let layer = currentSelectedVideoLayer(session: session) else { return }
1394
+ let (min, max, value): (CGFloat, CGFloat, CGFloat)
1395
+ switch activeVideoLayerPropertyKey {
1396
+ case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
1397
+ case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
1398
+ case "start": (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(layer.startMs))
1399
+ case "end":
1400
+ let effectiveEnd = (layer.endMs > 0 && layer.endMs <= session.durationMs) ? layer.endMs : session.durationMs
1401
+ (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(effectiveEnd))
1402
+ default: (min, max, value) = (20, 800, layer.scale * 100)
1403
+ }
1404
+ slider.minimumValue = Float(min)
1405
+ slider.maximumValue = Float(max)
1406
+ slider.value = Float(value)
1407
+ }
1408
+
1409
+ @objc private func videoLayerPropertyChanged(_ slider: UISlider) {
1410
+ guard let session = videoSession, let id = selectedVideoLayerID else { return }
1411
+ let value = CGFloat(slider.value)
1412
+ let key = activeVideoLayerPropertyKey
1413
+ session.layerStack.updateLive { list in
1414
+ list.map { layer in
1415
+ guard layer.id == id else { return layer }
1416
+ var updated = layer
1417
+ switch key {
1418
+ case "rotation": updated.rotationDegrees = value
1419
+ case "opacity": updated.opacity = value / 100
1420
+ case "start": updated.startMs = min(Int64(value), (layer.endMs > 0 ? layer.endMs : session.durationMs))
1421
+ case "end": updated.endMs = max(Int64(value), layer.startMs)
1422
+ default: updated.scale = value / 100
1423
+ }
1424
+ return updated
1425
+ }
1426
+ }
1427
+ refreshVideoOverlayPreview(session: session)
1428
+ }
1429
+
1430
+ private func makeVideoLayerToolBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
1431
+ let properties: [(String, String)] = [("scale", "Scale"), ("rotation", "Rotate"), ("opacity", "Opacity"), ("start", "Start"), ("end", "End")]
1432
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1433
+ let scroll = UIScrollView()
1434
+ let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
1435
+ properties.forEach { key, label in
1436
+ let chip = button(label, color: textColor) { [weak self] in
1437
+ self?.activeVideoLayerPropertyKey = key
1438
+ if let self { self.syncVideoLayerPropertySlider(session: session) }
1439
+ if let self { self.refreshSelection(self.videoLayerPropertyButtons, activeKey: self.activeVideoLayerPropertyKey, textColor: textColor, primaryColor: primaryColor) }
1440
+ }
1441
+ videoLayerPropertyButtons[key] = chip
1442
+ chipStack.addArrangedSubview(chip)
1443
+ }
1444
+ chipStack.addArrangedSubview(button("Delete", color: textColor) { [weak self] in
1445
+ guard let self, let id = self.selectedVideoLayerID else { return }
1446
+ session.layerStack.commit { $0.filter { $0.id != id } }
1447
+ self.selectVideoLayer(nil, session: session)
1448
+ self.onVideoLayerStackChanged()
1449
+ })
1450
+ scroll.addSubview(chipStack)
1451
+ chipStack.translatesAutoresizingMaskIntoConstraints = false
1452
+ NSLayoutConstraint.activate([
1453
+ chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
1454
+ chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
1455
+ chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1456
+ chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1457
+ chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1458
+ ])
1459
+ bar.addArrangedSubview(scroll)
1460
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in self?.selectVideoLayer(nil, session: session) })
1461
+ bar.backgroundColor = toolbarColor
1462
+ bar.isLayoutMarginsRelativeArrangement = true
1463
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1464
+ return bar
1465
+ }
1466
+
1467
+ private func showVideoTextInputDialog(session: VideoEditSession) {
1468
+ let alert = UIAlertController(title: "Add text", message: nil, preferredStyle: .alert)
1469
+ alert.addTextField { $0.placeholder = "Text" }
1470
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1471
+ alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
1472
+ guard let text = alert?.textFields?.first?.text, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
1473
+ var layer = PhotoLayer(type: .text)
1474
+ layer.text = text
1475
+ session.layerStack.commit { $0 + [layer] }
1476
+ self?.onVideoLayerStackChanged()
1477
+ self?.selectVideoLayer(layer.id, session: session)
1478
+ })
1479
+ present(alert, animated: true)
1480
+ }
1481
+
1482
+ @objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true }
1483
+
1484
+ @objc private func positionSliderTouchUp(_ slider: UISlider) {
1485
+ seekingPosition = false
1486
+ guard let session = videoSession, session.durationMs > 0 else { return }
1487
+ let targetMs = Double(slider.value / 1000) * Double(session.durationMs)
1488
+ session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
1489
+ }
1490
+
1491
+ @objc private func positionSliderChanged(_ slider: UISlider) {
1492
+ guard let session = videoSession, session.durationMs > 0 else { return }
1493
+ let targetMs = Double(slider.value / 1000) * Double(session.durationMs)
1494
+ session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
1495
+ }
1496
+
1497
+ private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController, textColor: UIColor, toolbarColor: UIColor) -> UIView {
1498
+ let tools: [(String, String)] = [
1499
+ ("cover", "Cover"), ("speed", "Speed 1x"), ("crop", "Crop"), ("rotate", "Rotate"),
1500
+ ("filters", "Filters"), ("text", "Text"), ("stickers", "Stickers"), ("overlay", "Overlay"),
1501
+ ]
1502
+ let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
1503
+ let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 8
1504
+ tools.filter { features[$0.0] as? Bool != false }.forEach { key, label in
1505
+ let toolButton = button(label, color: textColor) { [weak self, weak playerController] in
1506
+ self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
1507
+ }
1508
+ videoToolButtons[key] = toolButton
1509
+ stack.addArrangedSubview(toolButton)
1510
+ }
1511
+ scroll.addSubview(stack)
1512
+ stack.translatesAutoresizingMaskIntoConstraints = false
1513
+ NSLayoutConstraint.activate([
1514
+ stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
1515
+ stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
1516
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1517
+ stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1518
+ stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1519
+ ])
1520
+ return scroll
1521
+ }
1522
+
1523
+ private func refreshVideoToolSelection(session: VideoEditSession) {
1524
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1525
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1526
+ videoToolButtons.forEach { key, button in
1527
+ let selected: Bool
1528
+ switch key {
1529
+ case "crop": selected = videoAspectMode
1530
+ default: selected = false
1531
+ }
1532
+ setButtonSelected(button, selected: selected, textColor: textColor, primaryColor: primaryColor)
1533
+ }
1534
+ }
1535
+
1536
+ private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
1537
+ switch key {
1538
+ case "cover":
1539
+ let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1540
+ session.update { $0.coverFrameMs = atMs }
1541
+ showToast("Cover frame set at \(formatMs(atMs))")
1542
+ case "rotate":
1543
+ session.update { $0.rotateRight() }
1544
+ applyVideoPreviewTransform(playerView, state: session.state)
1545
+ case "crop":
1546
+ setVideoAspectMode(true, session: session)
1547
+ case "speed":
1548
+ session.update { $0.cycleSpeed() }
1549
+ if session.player.timeControlStatus == .playing { session.player.rate = session.state.speed }
1550
+ videoToolButtons["speed"]?.setTitle(formatSpeedLabel(session.state.speed), for: .normal)
1551
+ case "text":
1552
+ showVideoTextInputDialog(session: session)
1553
+ case "stickers":
1554
+ showVideoStickerPicker(session: session)
1555
+ case "overlay":
1556
+ presentImagePicker(purpose: .videoOverlay)
1557
+ default:
1558
+ showToast("\(key) is coming in a later milestone.")
1559
+ }
1560
+ }
1561
+
1562
+ private func showVideoStickerPicker(session: VideoEditSession) {
1563
+ let alert = UIAlertController(title: "Add sticker", message: nil, preferredStyle: .actionSheet)
1564
+ PhotoLayerRenderer.builtinStickerIDs().forEach { id in
1565
+ alert.addAction(UIAlertAction(title: PhotoLayerRenderer.glyph(for: id), style: .default) { [weak self] _ in
1566
+ var layer = PhotoLayer(type: .sticker)
1567
+ layer.stickerId = id
1568
+ session.layerStack.commit { $0 + [layer] }
1569
+ self?.onVideoLayerStackChanged()
1570
+ })
1571
+ }
1572
+ consumerStickerAssets().forEach { asset in
1573
+ alert.addAction(UIAlertAction(title: asset.id, style: .default) { [weak self] _ in
1574
+ var layer = PhotoLayer(type: .sticker)
1575
+ layer.stickerUri = asset.uri
1576
+ session.layerStack.commit { $0 + [layer] }
1577
+ self?.onVideoLayerStackChanged()
1578
+ })
1579
+ }
1580
+ alert.addAction(UIAlertAction(title: "Upload", style: .default) { [weak self] _ in
1581
+ self?.presentImagePicker(purpose: .videoSticker)
1582
+ })
1583
+ if features["onlineStickers"] as? Bool != false {
1584
+ alert.addAction(UIAlertAction(title: "Browse online", style: .default) { [weak self] _ in
1585
+ self?.presentOnlineStickerSheet(purpose: .videoSticker)
1586
+ })
1587
+ }
1588
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1589
+ if let popover = alert.popoverPresentationController {
1590
+ popover.sourceView = view
1591
+ popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 1, width: 1, height: 1)
1592
+ }
1593
+ present(alert, animated: true)
1594
+ }
1595
+
1596
+ private func formatSpeedLabel(_ speed: Float) -> String {
1597
+ let trimmed = speed == speed.rounded() ? String(Int(speed)) : String(speed)
1598
+ return "Speed \(trimmed)x"
1599
+ }
1600
+
1601
+ private func applyVideoPreviewTransform(_ playerView: UIView?, state: VideoTransformState) {
1602
+ playerView?.transform = CGAffineTransform(rotationAngle: CGFloat(state.rotationDegrees) * .pi / 180)
1603
+ }
1604
+
1605
+ private func setVideoAspectMode(_ enabled: Bool, session: VideoEditSession) {
1606
+ videoAspectMode = enabled
1607
+ videoAspectSubBar?.isHidden = !enabled
1608
+ videoToolBar?.isHidden = enabled
1609
+ refreshVideoToolSelection(session: session)
1610
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1611
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1612
+ refreshSelection(videoAspectButtons, activeKey: videoAspectRatioLabel(session.state.aspectRatio), textColor: textColor, primaryColor: primaryColor)
1613
+ }
1614
+
1615
+ private func videoAspectRatioLabel(_ ratio: CGFloat?) -> String {
1616
+ guard let ratio else { return "Original" }
1617
+ let knownRatios: [(String, CGFloat)] = [("1:1", 1), ("4:5", 4 / 5), ("9:16", 9 / 16), ("16:9", 16 / 9)]
1618
+ return knownRatios.first(where: { $0.1 == ratio })?.0 ?? "Original"
1619
+ }
1620
+
1621
+ private func makeVideoAspectSubBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
1622
+ let presets: [(String, CGFloat?)] = [("Original", nil), ("1:1", 1), ("4:5", 4 / 5), ("9:16", 9 / 16), ("16:9", 16 / 9)]
1623
+ let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
1624
+ let scroll = UIScrollView()
1625
+ let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
1626
+ presets.forEach { label, ratio in
1627
+ let chip = button(label, color: textColor) { [weak self] in
1628
+ session.update { $0.aspectRatio = ratio }
1629
+ self?.showToast("Export aspect: \(label) (Android only for now)")
1630
+ if let self { self.refreshSelection(self.videoAspectButtons, activeKey: label, textColor: textColor, primaryColor: primaryColor) }
1631
+ }
1632
+ videoAspectButtons[label] = chip
1633
+ chipStack.addArrangedSubview(chip)
1634
+ }
1635
+ scroll.addSubview(chipStack)
1636
+ chipStack.translatesAutoresizingMaskIntoConstraints = false
1637
+ NSLayoutConstraint.activate([
1638
+ chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
1639
+ chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
1640
+ chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1641
+ chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1642
+ chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
1643
+ ])
1644
+ bar.addArrangedSubview(scroll)
1645
+ bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in
1646
+ guard let self, let session = self.videoSession else { return }
1647
+ self.setVideoAspectMode(false, session: session)
1648
+ })
1649
+ bar.backgroundColor = toolbarColor
1650
+ bar.isLayoutMarginsRelativeArrangement = true
1651
+ bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
1652
+ return bar
1653
+ }
1654
+
1655
+ private func exportVideo() {
1656
+ guard let session = videoSession else {
1657
+ completion?(.failure(code: "E_INTERNAL", message: "Nothing to export."))
1658
+ return
1659
+ }
1660
+ session.player.pause()
1661
+ videoExportProgressOverlay?.isHidden = false
1662
+ let exporter = VideoExporter()
1663
+ videoExporter = exporter
1664
+ exporter.export(
1665
+ clips: session.clips,
1666
+ assetProvider: { session.asset(for: $0) },
1667
+ state: session.state,
1668
+ layers: session.layerStack.layers,
1669
+ exportOptions: exportOptions,
1670
+ onProgress: { _ in /* Progress UI is a spinner for now; see docs/video-editor.md. */ },
1671
+ onComplete: { [weak self] result in
1672
+ DispatchQueue.main.async {
1673
+ self?.completion?(.success(uri: result.uri, mimeType: result.mimeType, width: result.width, height: result.height, fileSize: result.fileSize, durationMs: result.durationMs))
1674
+ }
1675
+ },
1676
+ onError: { [weak self] error in
1677
+ DispatchQueue.main.async { self?.completion?(.failure(code: error.code, message: error.message)) }
1678
+ }
1679
+ )
1680
+ }
1681
+
1682
+ private func cancelVideoExport() {
1683
+ videoExporter?.cancel()
1684
+ videoExporter = nil
1685
+ videoExportProgressOverlay?.isHidden = true
1686
+ }
1687
+
1688
+ // MARK: - Multi-clip timeline (Milestone 7)
1689
+ //
1690
+ // Clip strip UI plus split/duplicate/delete/reorder, all operating on
1691
+ // `session.clips` via `session.updateClips`. Mirrors the Android
1692
+ // implementation's controls, adapted to `UIStackView`/`UIButton`.
1693
+
1694
+ /// Rebinds the trim range view to whichever clip is currently selected.
1695
+ private func bindTrimViewToSelectedClip(session: VideoEditSession) {
1696
+ guard let trim = trimRangeView, let clip = session.clips[safe: selectedClipIndex] else { return }
1697
+ let duration = max(1, clip.originalDurationMs)
1698
+ trim.setRange(
1699
+ start: CGFloat(clip.trimStartMs) / CGFloat(duration),
1700
+ end: CGFloat(clip.effectiveTrimEndMs()) / CGFloat(duration)
1701
+ )
1702
+ timelineThumbnailRequest?.cancel()
1703
+ trim.thumbnails = []
1704
+ let clipAsset = session.asset(for: clip)
1705
+ let repository = timelineThumbnailRepository ?? TimelineThumbnailRepository()
1706
+ timelineThumbnailRepository = repository
1707
+ timelineThumbnailFrames = [UIImage?](repeating: nil, count: 12)
1708
+ timelineThumbnailRequest = repository.load(
1709
+ mediaID: clip.sourceUri,
1710
+ asset: clipAsset,
1711
+ durationMs: clip.originalDurationMs,
1712
+ count: 12,
1713
+ size: CGSize(width: 144, height: 96),
1714
+ onThumbnail: { [weak self, weak trim] index, image in
1715
+ guard let self,
1716
+ self.videoSession?.clips[safe: self.selectedClipIndex]?.id == clip.id else { return }
1717
+ self.timelineThumbnailFrames[index] = image
1718
+ trim?.thumbnails = self.timelineThumbnailFrames.compactMap { $0 }
1719
+ },
1720
+ onComplete: { [weak self] in
1721
+ guard self?.videoSession?.clips[safe: self?.selectedClipIndex ?? -1]?.id == clip.id else { return }
1722
+ self?.timelineThumbnailRequest = nil
1723
+ }
1724
+ )
1725
+ }
1726
+
1727
+ /// Rebuilds the clip strip: one chip per clip plus Split/Duplicate/Delete/Move-Left/Move-Right actions.
1728
+ private func refreshClipStrip(session: VideoEditSession) {
1729
+ guard let strip = clipStripBar else { return }
1730
+ let textColor = color("textColor") ?? DesignTokens.onSurface
1731
+ let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
1732
+ strip.arrangedSubviews.forEach { $0.removeFromSuperview() }
1733
+ session.clips.enumerated().forEach { index, clip in
1734
+ let chip = button("Clip \(index + 1)\n\(formatMs(clip.trimmedDurationMs()))", color: textColor) { [weak self] in
1735
+ guard let self, let session = self.videoSession else { return }
1736
+ self.selectedClipIndex = index
1737
+ let precedingMs = session.clips.prefix(index).reduce(Int64(0)) { $0 + $1.trimmedDurationMs() }
1738
+ session.player.seek(to: CMTime(value: precedingMs, timescale: 1000))
1739
+ self.bindTrimViewToSelectedClip(session: session)
1740
+ self.refreshClipStrip(session: session)
1741
+ }
1742
+ chip.titleLabel?.numberOfLines = 2
1743
+ chip.titleLabel?.textAlignment = .center
1744
+ chip.titleLabel?.font = .systemFont(ofSize: 11)
1745
+ setButtonSelected(chip, selected: index == selectedClipIndex, textColor: textColor, primaryColor: primaryColor)
1746
+ strip.addArrangedSubview(chip)
1747
+ }
1748
+ strip.addArrangedSubview(button("Split", color: textColor) { [weak self] in self?.splitSelectedClip() })
1749
+ strip.addArrangedSubview(button("Copy", color: textColor) { [weak self] in self?.duplicateSelectedClip() })
1750
+ strip.addArrangedSubview(button("Delete", color: textColor) { [weak self] in self?.deleteSelectedClip() })
1751
+ strip.addArrangedSubview(button("<", color: textColor) { [weak self] in self?.moveSelectedClip(by: -1) })
1752
+ strip.addArrangedSubview(button(">", color: textColor) { [weak self] in self?.moveSelectedClip(by: 1) })
1753
+ }
1754
+
1755
+ /// Splits the selected clip at the current playhead into two clips sharing the same source.
1756
+ private func splitSelectedClip() {
1757
+ guard let session = videoSession, let clip = session.clips[safe: selectedClipIndex] else { return }
1758
+ let index = selectedClipIndex
1759
+ let precedingMs = session.clips.prefix(index).reduce(Int64(0)) { $0 + $1.trimmedDurationMs() }
1760
+ let globalMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
1761
+ let splitWithinClipMs = min(max(globalMs - precedingMs, 0), clip.trimmedDurationMs())
1762
+ let splitSourceMs = clip.trimStartMs + splitWithinClipMs
1763
+ guard splitSourceMs > clip.trimStartMs + 50, splitSourceMs < clip.effectiveTrimEndMs() - 50 else {
1764
+ showToast("Move the playhead into the clip first to split it there.")
1765
+ return
1766
+ }
1767
+ session.updateClips { clips in
1768
+ clips.enumerated().flatMap { i, c -> [VideoClip] in
1769
+ guard i == index else { return [c] }
1770
+ var first = c
1771
+ first.trimEndMs = splitSourceMs
1772
+ let second = VideoClip(sourceUri: c.sourceUri, originalDurationMs: c.originalDurationMs, trimStartMs: splitSourceMs, trimEndMs: c.effectiveTrimEndMs())
1773
+ return [first, second]
1774
+ }
1775
+ }
1776
+ refreshClipStrip(session: session)
1777
+ }
1778
+
1779
+ /// Duplicates the selected clip immediately after itself.
1780
+ private func duplicateSelectedClip() {
1781
+ guard let session = videoSession else { return }
1782
+ let index = selectedClipIndex
1783
+ session.updateClips { clips in
1784
+ clips.enumerated().flatMap { i, c -> [VideoClip] in
1785
+ guard i == index else { return [c] }
1786
+ var copy = c
1787
+ copy.id = UUID().uuidString
1788
+ return [c, copy]
1789
+ }
1790
+ }
1791
+ selectedClipIndex = index + 1
1792
+ bindTrimViewToSelectedClip(session: session)
1793
+ refreshClipStrip(session: session)
1794
+ }
1795
+
1796
+ /// Deletes the selected clip, refusing to leave the timeline empty.
1797
+ private func deleteSelectedClip() {
1798
+ guard let session = videoSession else { return }
1799
+ guard session.clips.count > 1 else {
1800
+ showToast("A video needs at least one clip.")
1801
+ return
1802
+ }
1803
+ let index = selectedClipIndex
1804
+ session.updateClips { clips in clips.enumerated().filter { $0.offset != index }.map(\.element) }
1805
+ selectedClipIndex = min(selectedClipIndex, session.clips.count - 1)
1806
+ bindTrimViewToSelectedClip(session: session)
1807
+ refreshClipStrip(session: session)
1808
+ }
1809
+
1810
+ /// Moves the selected clip one position left (-1) or right (+1) in the timeline.
1811
+ private func moveSelectedClip(by direction: Int) {
1812
+ guard let session = videoSession else { return }
1813
+ let index = selectedClipIndex
1814
+ let target = index + direction
1815
+ guard target >= 0, target < session.clips.count else { return }
1816
+ session.updateClips { clips in
1817
+ var mutable = clips
1818
+ let moved = mutable.remove(at: index)
1819
+ mutable.insert(moved, at: target)
1820
+ return mutable
1821
+ }
1822
+ selectedClipIndex = target
1823
+ refreshClipStrip(session: session)
1824
+ }
1825
+
1826
+ // MARK: - Shared shell
1827
+
1828
+ /// Studio Violet header: circular close button, centered title, icon Undo/Redo, pill Export CTA.
1829
+ /// Mirrors the Android header reskin in `PhotoVideoEditorActivity.createTopBar`.
1830
+ private func makeHeader(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
1831
+ let header = UIStackView(); header.axis = .horizontal; header.alignment = .center
1832
+ header.backgroundColor = toolbarColor
1833
+ header.isLayoutMarginsRelativeArrangement = true
1834
+ header.layoutMargins = UIEdgeInsets(top: 0, left: DesignTokens.spaceLg, bottom: 0, right: DesignTokens.spaceLg)
1835
+ header.spacing = DesignTokens.spaceXs
1836
+
1837
+ let close = iconButton(systemName: "xmark", tint: textColor) { [weak self] in self?.cancelEditor() }
1838
+ close.backgroundColor = DesignTokens.surfaceContainerLow
1839
+ close.layer.cornerRadius = DesignTokens.touchTargetMin / 2
1840
+ close.clipsToBounds = true
1841
+
1842
+ let title = UILabel()
1843
+ title.text = mediaType == "photo" ? "Photo Editor" : "Video Editor"
1844
+ title.textColor = textColor
1845
+ title.font = .systemFont(ofSize: 16, weight: .bold)
1846
+ title.textAlignment = .center
1847
+
1848
+ header.addArrangedSubview(close)
1849
+ header.addArrangedSubview(title)
1850
+
1851
+ if mediaType == "photo" {
1852
+ let undo = iconButton(systemName: "arrow.uturn.backward", tint: textColor) { [weak self] in
1853
+ self?.photoSession?.layerStack.undo()
1854
+ self?.onLayerStackChanged()
1855
+ }
1856
+ let redo = iconButton(systemName: "arrow.uturn.forward", tint: textColor) { [weak self] in
1857
+ self?.photoSession?.layerStack.redo()
1858
+ self?.onLayerStackChanged()
1859
+ }
1860
+ undoButton = undo
1861
+ redoButton = redo
1862
+ header.addArrangedSubview(undo)
1863
+ header.addArrangedSubview(redo)
1864
+ let compare = iconButton(systemName: "rectangle.split.2x1", tint: textColor, handler: nil)
1865
+ compare.accessibilityLabel = "Hold to compare with original"
1866
+ compare.addTarget(self, action: #selector(compareTouchDown), for: .touchDown)
1867
+ compare.addTarget(self, action: #selector(compareTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
1868
+ header.addArrangedSubview(compare)
1869
+ } else if mediaType == "video" {
1870
+ let undo = iconButton(systemName: "arrow.uturn.backward", tint: textColor) { [weak self] in
1871
+ self?.videoSession?.layerStack.undo()
1872
+ self?.onVideoLayerStackChanged()
1873
+ }
1874
+ let redo = iconButton(systemName: "arrow.uturn.forward", tint: textColor) { [weak self] in
1875
+ self?.videoSession?.layerStack.redo()
1876
+ self?.onVideoLayerStackChanged()
1877
+ }
1878
+ undoButton = undo
1879
+ redoButton = redo
1880
+ header.addArrangedSubview(undo)
1881
+ header.addArrangedSubview(redo)
1882
+ }
1883
+
1884
+ let onPrimaryColor = color("onPrimaryColor") ?? DesignTokens.onPrimaryContainer
1885
+ let export = EditorComponents.primaryButton(
1886
+ text: "Export",
1887
+ systemImage: "square.and.arrow.up",
1888
+ fillColor: primaryColor,
1889
+ textColor: onPrimaryColor
1890
+ ) { [weak self] in
1891
+ guard let self else { return }
1892
+ if self.mediaType == "photo" { self.showPhotoExportConfiguration() } else { self.doneEditor() }
1893
+ }
1894
+ header.addArrangedSubview(export)
1895
+
1896
+ title.setContentHuggingPriority(.defaultLow, for: .horizontal)
1897
+ return header
1898
+ }
1899
+
1900
+ /// 44x44pt icon-only button, matching the Studio Violet "Tool Icon Button" spec.
1901
+ private func iconButton(systemName: String, tint: UIColor, handler: (() -> Void)?) -> UIButton {
1902
+ let value = UIButton(type: .system)
1903
+ value.setImage(UIImage(systemName: systemName), for: .normal)
1904
+ value.tintColor = tint
1905
+ value.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
1906
+ value.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
1907
+ if let handler { value.addAction(UIAction { _ in handler() }, for: .touchUpInside) }
1908
+ value.applyPressScale()
1909
+ return value
1910
+ }
1911
+
1912
+ private func button(_ title: String, color: UIColor = .white, handler: (() -> Void)?) -> UIButton {
1913
+ let value = UIButton(type: .system)
1914
+ value.setTitle(title, for: .normal)
1915
+ value.setTitleColor(color, for: .normal)
1916
+ value.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
1917
+ value.backgroundColor = DesignTokens.surfaceContainerHigh
1918
+ value.layer.cornerRadius = DesignTokens.radiusLg
1919
+ value.contentEdgeInsets = UIEdgeInsets(top: 0, left: DesignTokens.spaceMd, bottom: 0, right: DesignTokens.spaceMd)
1920
+ value.widthAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true
1921
+ value.heightAnchor.constraint(greaterThanOrEqualToConstant: DesignTokens.touchTargetMin).isActive = true
1922
+ if let handler { value.addAction(UIAction { _ in handler() }, for: .touchUpInside) }
1923
+ return value
1924
+ }
1925
+
1926
+ /// Highlights a tool/chip button so the user can tell at a glance which one is active.
1927
+ private func setButtonSelected(_ button: UIButton, selected: Bool, textColor: UIColor, primaryColor: UIColor) {
1928
+ button.setTitleColor(selected ? DesignTokens.onPrimaryContainer : textColor, for: .normal)
1929
+ button.backgroundColor = selected ? primaryColor : DesignTokens.surfaceContainerHigh
1930
+ }
1931
+
1932
+ private func refreshSelection<K: Hashable>(_ buttons: [K: UIButton], activeKey: K?, textColor: UIColor, primaryColor: UIColor) {
1933
+ buttons.forEach { key, button in setButtonSelected(button, selected: key == activeKey, textColor: textColor, primaryColor: primaryColor) }
1934
+ }
1935
+
1936
+ private func showToast(_ message: String) {
1937
+ let label = UILabel()
1938
+ label.text = " \(message) "
1939
+ label.textColor = .white
1940
+ label.backgroundColor = UIColor.black.withAlphaComponent(0.75)
1941
+ label.textAlignment = .center
1942
+ label.numberOfLines = 0
1943
+ label.layer.cornerRadius = 8
1944
+ label.clipsToBounds = true
1945
+ label.alpha = 0
1946
+ label.translatesAutoresizingMaskIntoConstraints = false
1947
+ view.addSubview(label)
1948
+ NSLayoutConstraint.activate([
1949
+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
1950
+ label.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -96),
1951
+ label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
1952
+ label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24),
1953
+ ])
1954
+ UIView.animate(withDuration: 0.2, animations: { label.alpha = 1 }) { _ in
1955
+ UIView.animate(withDuration: 0.2, delay: 1.2, options: [], animations: { label.alpha = 0 }) { _ in label.removeFromSuperview() }
1956
+ }
1957
+ }
1958
+
1959
+ @objc private func compareTouchDown() {
1960
+ guard !comparingOriginal, let session = photoSession else { return }
1961
+ comparingOriginal = true
1962
+ photoImageView?.image = session.baseImage
1963
+ }
1964
+
1965
+ @objc private func compareTouchUp() {
1966
+ guard comparingOriginal else { return }
1967
+ comparingOriginal = false
1968
+ schedulePhotoPreviewRender()
1969
+ }
1970
+
1971
+ private func showPhotoExportConfiguration() {
1972
+ let sheet = UIAlertController(
1973
+ title: "Export & Share",
1974
+ message: "Choose an output format. Quality and size limits continue to use your export settings.",
1975
+ preferredStyle: .actionSheet
1976
+ )
1977
+ [("JPEG", "jpeg"), ("PNG", "png"), ("WebP", "webp")].forEach { label, format in
1978
+ sheet.addAction(UIAlertAction(title: label, style: .default) { [weak self] _ in
1979
+ self?.selectedPhotoExportFormat = format
1980
+ self?.doneEditor()
1981
+ })
1982
+ }
1983
+ sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1984
+ if let popover = sheet.popoverPresentationController {
1985
+ popover.sourceView = view
1986
+ popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 1, width: 1, height: 1)
1987
+ }
1988
+ present(sheet, animated: true)
1989
+ }
1990
+
1991
+ /// Coalesces slider and drag callbacks so bitmap composition runs at most once per display frame.
1992
+ private func schedulePhotoPreviewRender() {
1993
+ guard !photoPreviewRenderPending else { return }
1994
+ photoPreviewRenderPending = true
1995
+ DispatchQueue.main.async { [weak self] in
1996
+ guard let self else { return }
1997
+ self.photoPreviewRenderPending = false
1998
+ guard self.viewIfLoaded?.window != nil,
1999
+ let session = self.photoSession,
2000
+ let imageView = self.photoImageView else { return }
2001
+ autoreleasepool { imageView.image = session.renderPreview() }
2002
+ }
2003
+ }
2004
+
2005
+ private func cancelEditor() {
2006
+ let alert = UIAlertController(
2007
+ title: "Discard Unsaved Changes?",
2008
+ message: "If you exit now, your edits will be lost.",
2009
+ preferredStyle: .alert
2010
+ )
2011
+ alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
2012
+ alert.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) { [weak self] _ in
2013
+ self?.videoExporter?.cancel()
2014
+ self?.completion?(.cancelled)
2015
+ })
2016
+ present(alert, animated: true)
2017
+ }
2018
+
2019
+ private func doneEditor() {
2020
+ if mediaType == "photo" {
2021
+ exportPhoto()
2022
+ } else {
2023
+ exportVideo()
2024
+ }
2025
+ }
2026
+
2027
+ override func didReceiveMemoryWarning() {
2028
+ super.didReceiveMemoryWarning()
2029
+ timelineThumbnailRepository?.clearMemory()
2030
+ }
2031
+
2032
+ deinit {
2033
+ positionPollTimer?.invalidate()
2034
+ timelineThumbnailRequest?.cancel()
2035
+ timelineThumbnailRepository?.close()
2036
+ photoSession?.release()
2037
+ videoSession?.release()
2038
+ }
2039
+ }
2040
+
2041
+ private extension Array {
2042
+ subscript(safe index: Int) -> Element? {
2043
+ indices.contains(index) ? self[index] : nil
2044
+ }
2045
+ }
2046
+
2047
+ extension PhotoVideoEditorViewController: PHPickerViewControllerDelegate {
2048
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
2049
+ picker.dismiss(animated: true)
2050
+ guard let purpose = pendingPickPurpose, let provider = results.first?.itemProvider else {
2051
+ pendingPickPurpose = nil
2052
+ return
2053
+ }
2054
+ pendingPickPurpose = nil
2055
+ guard provider.canLoadObject(ofClass: UIImage.self) else { return }
2056
+ // `loadObject`'s completion runs off the main thread — hop back before touching UI/session state.
2057
+ provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
2058
+ guard let self, let image = object as? UIImage, let fileURL = self.writeTempImage(image) else { return }
2059
+ DispatchQueue.main.async {
2060
+ self.handlePickedImage(fileURL, image: image, purpose: purpose)
2061
+ }
2062
+ }
2063
+ }
2064
+ }