react-native-photo-video-editor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/PhotoVideoEditor.podspec +1 -0
- package/README.md +10 -6
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/android/build.gradle +1 -1
- package/android/src/main/AndroidManifest.xml +1 -1
- package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
- package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
- package/android/src/main/assets/Stickers/LOL_1.png +0 -0
- package/android/src/main/assets/Stickers/LOL_10.png +0 -0
- package/android/src/main/assets/Stickers/LOL_11.png +0 -0
- package/android/src/main/assets/Stickers/LOL_12.png +0 -0
- package/android/src/main/assets/Stickers/LOL_13.png +0 -0
- package/android/src/main/assets/Stickers/LOL_14.png +0 -0
- package/android/src/main/assets/Stickers/LOL_15.png +0 -0
- package/android/src/main/assets/Stickers/LOL_16.png +0 -0
- package/android/src/main/assets/Stickers/LOL_17.png +0 -0
- package/android/src/main/assets/Stickers/LOL_18.png +0 -0
- package/android/src/main/assets/Stickers/LOL_19.png +0 -0
- package/android/src/main/assets/Stickers/LOL_2.png +0 -0
- package/android/src/main/assets/Stickers/LOL_3.png +0 -0
- package/android/src/main/assets/Stickers/LOL_4.png +0 -0
- package/android/src/main/assets/Stickers/LOL_5.png +0 -0
- package/android/src/main/assets/Stickers/LOL_6.png +0 -0
- package/android/src/main/assets/Stickers/LOL_7.png +0 -0
- package/android/src/main/assets/Stickers/LOL_8.png +0 -0
- package/android/src/main/assets/Stickers/LOL_9.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
- package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
- package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
- package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +901 -651
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
- package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +24 -1
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +112 -122
- package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +63 -14
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
- package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
- package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
- package/android/src/main/res/drawable/ic_draw.xml +6 -0
- package/android/src/main/res/drawable/ic_pause.xml +7 -0
- package/android/src/main/res/drawable/ic_play.xml +7 -0
- package/docs/api.md +1 -1
- package/docs/editor-ui-validation.md +22 -0
- package/docs/photo-editor.md +5 -6
- package/ios/Editor/PhotoVideoEditorViewController.swift +918 -494
- package/ios/Editor/UI/CropPanel.swift +97 -0
- package/ios/Editor/UI/EditorPanel.swift +334 -0
- package/ios/Editor/UI/EditorToolSlider.swift +22 -0
- package/ios/Editor/UI/TextEditorSheet.swift +84 -0
- package/ios/Photo/Export/PhotoExporter.swift +3 -3
- package/ios/Photo/Rendering/CropGeometry.swift +48 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
- package/ios/Photo/Rendering/PhotoLayer.swift +14 -2
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +16 -1
- package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
- package/ios/Photo/UI/CropOverlayView.swift +98 -134
- package/ios/Photo/UI/DrawOverlayView.swift +48 -0
- package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
- package/ios/Photo/UI/LayerOverlayView.swift +36 -11
- package/ios/Photo/UI/ZoomableImageView.swift +19 -1
- package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
- package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
- package/ios/Resources/Stickers/LOL_1.png +0 -0
- package/ios/Resources/Stickers/LOL_10.png +0 -0
- package/ios/Resources/Stickers/LOL_11.png +0 -0
- package/ios/Resources/Stickers/LOL_12.png +0 -0
- package/ios/Resources/Stickers/LOL_13.png +0 -0
- package/ios/Resources/Stickers/LOL_14.png +0 -0
- package/ios/Resources/Stickers/LOL_15.png +0 -0
- package/ios/Resources/Stickers/LOL_16.png +0 -0
- package/ios/Resources/Stickers/LOL_17.png +0 -0
- package/ios/Resources/Stickers/LOL_18.png +0 -0
- package/ios/Resources/Stickers/LOL_19.png +0 -0
- package/ios/Resources/Stickers/LOL_2.png +0 -0
- package/ios/Resources/Stickers/LOL_3.png +0 -0
- package/ios/Resources/Stickers/LOL_4.png +0 -0
- package/ios/Resources/Stickers/LOL_5.png +0 -0
- package/ios/Resources/Stickers/LOL_6.png +0 -0
- package/ios/Resources/Stickers/LOL_7.png +0 -0
- package/ios/Resources/Stickers/LOL_8.png +0 -0
- package/ios/Resources/Stickers/LOL_9.png +0 -0
- package/ios/Resources/Stickers/SPRING_1.png +0 -0
- package/ios/Resources/Stickers/SPRING_2.png +0 -0
- package/ios/Resources/Stickers/SPRING_3.png +0 -0
- package/ios/Resources/Stickers/SPRING_4.png +0 -0
- package/ios/Resources/Stickers/SPRING_5.png +0 -0
- package/ios/Resources/Stickers/SPRING_6.png +0 -0
- package/ios/Resources/Stickers/SUMMER_1.png +0 -0
- package/ios/Resources/Stickers/SUMMER_10.png +0 -0
- package/ios/Resources/Stickers/SUMMER_11.png +0 -0
- package/ios/Resources/Stickers/SUMMER_12.png +0 -0
- package/ios/Resources/Stickers/SUMMER_13.png +0 -0
- package/ios/Resources/Stickers/SUMMER_14.png +0 -0
- package/ios/Resources/Stickers/SUMMER_15.png +0 -0
- package/ios/Resources/Stickers/SUMMER_16.png +0 -0
- package/ios/Resources/Stickers/SUMMER_17.png +0 -0
- package/ios/Resources/Stickers/SUMMER_18.png +0 -0
- package/ios/Resources/Stickers/SUMMER_19.png +0 -0
- package/ios/Resources/Stickers/SUMMER_2.png +0 -0
- package/ios/Resources/Stickers/SUMMER_20.png +0 -0
- package/ios/Resources/Stickers/SUMMER_21.png +0 -0
- package/ios/Resources/Stickers/SUMMER_3.png +0 -0
- package/ios/Resources/Stickers/SUMMER_4.png +0 -0
- package/ios/Resources/Stickers/SUMMER_5.png +0 -0
- package/ios/Resources/Stickers/SUMMER_6.png +0 -0
- package/ios/Resources/Stickers/SUMMER_7.png +0 -0
- package/ios/Resources/Stickers/SUMMER_8.png +0 -0
- package/ios/Resources/Stickers/SUMMER_9.png +0 -0
- package/ios/Resources/Stickers/TEXT_1.png +0 -0
- package/ios/Resources/Stickers/TEXT_12.png +0 -0
- package/ios/Resources/Stickers/TEXT_13.png +0 -0
- package/ios/Resources/Stickers/TEXT_14.png +0 -0
- package/ios/Resources/Stickers/TEXT_15.png +0 -0
- package/ios/Resources/Stickers/TEXT_16.png +0 -0
- package/ios/Resources/Stickers/TEXT_2.png +0 -0
- package/ios/Resources/Stickers/TEXT_25.png +0 -0
- package/ios/Resources/Stickers/TEXT_26.png +0 -0
- package/ios/Resources/Stickers/TEXT_27.png +0 -0
- package/ios/Resources/Stickers/TEXT_28.png +0 -0
- package/ios/Resources/Stickers/TEXT_29.png +0 -0
- package/ios/Resources/Stickers/TEXT_3.png +0 -0
- package/ios/Resources/Stickers/TEXT_30.png +0 -0
- package/ios/Resources/Stickers/TEXT_31.png +0 -0
- package/ios/Resources/Stickers/TEXT_32.png +0 -0
- package/ios/Resources/Stickers/TEXT_7.png +0 -0
- package/ios/Resources/Stickers/TEXT_8.png +0 -0
- package/ios/Resources/Stickers/TEXT_9.png +0 -0
- package/ios/Resources/Stickers/a_pety_icon.png +0 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
- package/ios/Video/Export/VideoExporter.swift +10 -1
- package/ios/Video/Render/VideoCropGeometry.swift +48 -0
- package/ios/Video/Render/VideoTransformState.swift +1 -0
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/types.d.ts +2 -2
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +1 -1
- package/src/types.ts +2 -2
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
- package/ios/Stickers/OpenMojiCatalog.swift +0 -188
- package/ios/Stickers/RemoteImageLoader.swift +0 -127
|
@@ -20,21 +20,36 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
20
20
|
|
|
21
21
|
private var photoSession: PhotoEditSession?
|
|
22
22
|
private var cropMode = false
|
|
23
|
+
private var cropEntryState: PhotoTransformState?
|
|
24
|
+
private var photoCropPanel: CropPanel?
|
|
23
25
|
private var filtersMode = false
|
|
26
|
+
private var drawMode = false
|
|
24
27
|
private var stickersMode = false
|
|
25
28
|
private var pendingPickPurpose: ImagePickPurpose?
|
|
26
29
|
private var activeAdjustmentKey = "brightness"
|
|
27
30
|
private var selectedLayerID: String?
|
|
28
|
-
private var activeLayerPropertyKey = "
|
|
31
|
+
private var activeLayerPropertyKey = ""
|
|
29
32
|
private var dragStartSnapshot: [PhotoLayer]?
|
|
30
33
|
private var photoImageView: ZoomableImageView?
|
|
31
34
|
private var cropOverlay: CropOverlayView?
|
|
32
35
|
private var layerOverlay: LayerOverlayView?
|
|
36
|
+
private var drawOverlay: DrawOverlayView?
|
|
33
37
|
private var straightenSlider: UISlider?
|
|
34
38
|
private var cropSubBar: UIView?
|
|
35
|
-
|
|
39
|
+
/// Drives whichever adjustment is selected in the Adjust panel.
|
|
40
|
+
private var adjustmentSliderControl: EditorValueSlider?
|
|
41
|
+
/// Filter strength, owned by the Filters panel (a view has one superview, so not shared).
|
|
42
|
+
private var filterIntensityControl: EditorValueSlider?
|
|
43
|
+
private var adjustSubBar: UIView?
|
|
36
44
|
private var filtersSubBar: UIView?
|
|
45
|
+
private var adjustMode = false
|
|
46
|
+
/// Adjustment key (plus "mirror") -> its entry in the Adjust panel's strip.
|
|
47
|
+
private var adjustmentStripItems: [String: EditorStripItem] = [:]
|
|
48
|
+
/// Preset id ("" = Original) -> its thumbnail in the Filters carousel.
|
|
49
|
+
private var filterThumbnails: [String: EditorFilterThumbnail] = [:]
|
|
50
|
+
private let filterThumbnailLoader = FilterThumbnailLoader()
|
|
37
51
|
private var stickersSubBar: UIView?
|
|
52
|
+
private var drawSubBar: UIView?
|
|
38
53
|
private var layerToolBar: UIView?
|
|
39
54
|
private var layerPropertySlider: UISlider?
|
|
40
55
|
private var layerColorSwatchRow: UIView?
|
|
@@ -49,6 +64,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
49
64
|
private var videoSession: VideoEditSession?
|
|
50
65
|
private var videoExporter: VideoExporter?
|
|
51
66
|
private var videoAspectMode = false
|
|
67
|
+
private var videoCropMode = false
|
|
68
|
+
private var videoCropEntryState: PhotoTransformState?
|
|
69
|
+
private var videoCropPanel: CropPanel?
|
|
70
|
+
private var videoCropOverlay: CropOverlayView?
|
|
71
|
+
private weak var cropPlayerView: UIView?
|
|
72
|
+
private weak var videoPreviewContainer: UIView?
|
|
52
73
|
private var trimRangeView: TrimRangeView?
|
|
53
74
|
private var positionSlider: UISlider?
|
|
54
75
|
private var playPauseButton: UIButton?
|
|
@@ -64,10 +85,17 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
64
85
|
private var videoLayerToolBar: UIView?
|
|
65
86
|
private var videoLayerPropertySlider: UISlider?
|
|
66
87
|
private var selectedVideoLayerID: String?
|
|
67
|
-
private var activeVideoLayerPropertyKey = "
|
|
88
|
+
private var activeVideoLayerPropertyKey = ""
|
|
68
89
|
private var videoDragStartSnapshot: [PhotoLayer]?
|
|
90
|
+
private var videoColorRow: UIView?
|
|
91
|
+
private var textSwatches: [(UIColor, UIView)] = []
|
|
92
|
+
private var videoTextSwatches: [(UIColor, UIView)] = []
|
|
93
|
+
private var sliderSnapshot: [PhotoLayer]?
|
|
69
94
|
private var videoLayerPropertyButtons: [String: UIButton] = [:]
|
|
70
95
|
private var seekingPosition = false
|
|
96
|
+
private weak var videoControlsOverlay: UIView?
|
|
97
|
+
private var videoControlsVisible = true
|
|
98
|
+
private var hideVideoControlsTimer: Timer?
|
|
71
99
|
private var selectedClipIndex = 0
|
|
72
100
|
private var clipStripBar: UIStackView?
|
|
73
101
|
private var clipStripScroll: UIScrollView?
|
|
@@ -76,10 +104,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
76
104
|
private var timelineThumbnailFrames = [UIImage?](repeating: nil, count: 12)
|
|
77
105
|
|
|
78
106
|
private var cropAspectButtons: [String: UIButton] = [:]
|
|
79
|
-
private var filterAdjustmentButtons: [String: UIButton] = [:]
|
|
80
|
-
private var filterPresetButtons: [String: UIButton] = [:]
|
|
81
107
|
private var layerPropertyButtons: [String: UIButton] = [:]
|
|
82
|
-
private var layerIconButtons: [String: UIButton] = [:]
|
|
83
108
|
private var videoToolButtons: [String: UIButton] = [:]
|
|
84
109
|
private var videoAspectButtons: [String: UIButton] = [:]
|
|
85
110
|
|
|
@@ -186,30 +211,39 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
186
211
|
layers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
187
212
|
])
|
|
188
213
|
|
|
189
|
-
let
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
metadataBadge.layer.cornerRadius = DesignTokens.radiusLg
|
|
195
|
-
metadataBadge.clipsToBounds = true
|
|
196
|
-
preview.addSubview(metadataBadge)
|
|
197
|
-
metadataBadge.translatesAutoresizingMaskIntoConstraints = false
|
|
214
|
+
let drawing = DrawOverlayView()
|
|
215
|
+
drawing.isHidden = true
|
|
216
|
+
drawOverlay = drawing
|
|
217
|
+
preview.addSubview(drawing)
|
|
218
|
+
drawing.translatesAutoresizingMaskIntoConstraints = false
|
|
198
219
|
NSLayoutConstraint.activate([
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
drawing.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
|
|
221
|
+
drawing.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
|
|
222
|
+
drawing.topAnchor.constraint(equalTo: preview.topAnchor),
|
|
223
|
+
drawing.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
202
224
|
])
|
|
203
225
|
|
|
204
226
|
imageView.onBoundsChanged = { [weak self] bounds in
|
|
205
227
|
guard let self else { return }
|
|
206
|
-
if self.cropMode { overlay.
|
|
228
|
+
if self.cropMode { overlay.restore(bounds:bounds,state:session.state) }
|
|
207
229
|
layers.setImageBounds(bounds)
|
|
230
|
+
drawing.setImageBounds(bounds)
|
|
208
231
|
}
|
|
209
232
|
overlay.onCropChanged = { left, top, right, bottom in
|
|
210
233
|
session.update { $0.setCrop(left: left, top: top, right: right, bottom: bottom) }
|
|
211
234
|
}
|
|
235
|
+
overlay.onMediaBoundsChanged = { [weak imageView] bounds in imageView?.setRenderedBounds(bounds) }
|
|
236
|
+
overlay.onViewportChanged = { zoom,x,y in session.update { $0.zoom = zoom; $0.panX = x; $0.panY = y } }
|
|
237
|
+
|
|
238
|
+
layers.onLayerDelete = { [weak self] id in
|
|
239
|
+
session.layerStack.commit { $0.filter { $0.id != id } }
|
|
240
|
+
self?.selectLayer(nil); self?.onLayerStackChanged()
|
|
241
|
+
}
|
|
212
242
|
layers.onLayerTapped = { [weak self] id in self?.selectLayer(id) }
|
|
243
|
+
layers.onLayerDoubleTapped = { [weak self] id in
|
|
244
|
+
guard let self, let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) else { return }
|
|
245
|
+
self.showTextInputDialog(session: session, editingLayer: layer)
|
|
246
|
+
}
|
|
213
247
|
layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
214
248
|
guard let self, let session = self.photoSession else { return }
|
|
215
249
|
if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
|
|
@@ -238,12 +272,20 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
238
272
|
cropBar.isHidden = true
|
|
239
273
|
cropSubBar = cropBar
|
|
240
274
|
|
|
241
|
-
let
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
275
|
+
let adjustmentControl = EditorValueSlider(accentColor: primaryColor)
|
|
276
|
+
adjustmentControl.slider.addTarget(self, action: #selector(adjustmentChanged(_:)), for: .valueChanged)
|
|
277
|
+
adjustmentSliderControl = adjustmentControl
|
|
278
|
+
|
|
279
|
+
let intensityControl = EditorValueSlider(accentColor: primaryColor)
|
|
280
|
+
intensityControl.titleText = "Intensity"
|
|
281
|
+
intensityControl.slider.addTarget(self, action: #selector(filterIntensityChanged(_:)), for: .valueChanged)
|
|
282
|
+
filterIntensityControl = intensityControl
|
|
283
|
+
|
|
284
|
+
let adjustBar = makeAdjustSubBar(session: session, primaryColor: primaryColor)
|
|
285
|
+
adjustBar.isHidden = true
|
|
286
|
+
adjustSubBar = adjustBar
|
|
245
287
|
|
|
246
|
-
let filtersBar = makeFiltersSubBar(session: session,
|
|
288
|
+
let filtersBar = makeFiltersSubBar(session: session, primaryColor: primaryColor)
|
|
247
289
|
filtersBar.isHidden = true
|
|
248
290
|
filtersSubBar = filtersBar
|
|
249
291
|
|
|
@@ -251,9 +293,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
251
293
|
stickersBar.isHidden = true
|
|
252
294
|
stickersSubBar = stickersBar
|
|
253
295
|
|
|
254
|
-
let
|
|
296
|
+
let drawBar = makeDrawSubBar(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
|
|
297
|
+
drawBar.isHidden = true
|
|
298
|
+
drawSubBar = drawBar
|
|
299
|
+
|
|
300
|
+
let layerSlider = EditorToolSlider()
|
|
255
301
|
layerSlider.isHidden = true
|
|
256
302
|
layerSlider.addTarget(self, action: #selector(layerPropertyChanged(_:)), for: .valueChanged)
|
|
303
|
+
configureLayerSlider(layerSlider, video: false)
|
|
257
304
|
layerPropertySlider = layerSlider
|
|
258
305
|
|
|
259
306
|
let layerBar = makeLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
|
|
@@ -267,7 +314,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
267
314
|
let toolbar = makePhotoToolBar(textColor: textColor, toolbarColor: toolbarColor)
|
|
268
315
|
mainToolBar = toolbar
|
|
269
316
|
|
|
270
|
-
let root = UIStackView(arrangedSubviews: [header, preview,
|
|
317
|
+
let root = UIStackView(arrangedSubviews: [header, preview, cropBar, adjustBar, filtersBar, stickersBar, drawBar, layerSlider, colorSwatchRow, layerBar, toolbar])
|
|
271
318
|
root.axis = .vertical
|
|
272
319
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
273
320
|
view.addSubview(root)
|
|
@@ -278,13 +325,18 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
278
325
|
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
279
326
|
header.heightAnchor.constraint(equalToConstant: 60),
|
|
280
327
|
toolbar.heightAnchor.constraint(equalToConstant: 72),
|
|
281
|
-
cropBar.heightAnchor.constraint(equalToConstant:
|
|
282
|
-
|
|
328
|
+
cropBar.heightAnchor.constraint(equalToConstant: 232), // rotate row (44pt) + aspect chip row (56pt)
|
|
329
|
+
adjustBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight),
|
|
330
|
+
filtersBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight + 24),
|
|
283
331
|
stickersBar.heightAnchor.constraint(equalToConstant: 56),
|
|
284
|
-
|
|
332
|
+
drawBar.heightAnchor.constraint(equalToConstant: 56),
|
|
333
|
+
layerBar.heightAnchor.constraint(equalToConstant: 76),
|
|
285
334
|
colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
|
|
286
335
|
])
|
|
287
336
|
|
|
337
|
+
root.arrangedSubviews.forEach { child in
|
|
338
|
+
child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
|
|
339
|
+
}
|
|
288
340
|
let progress = UIView()
|
|
289
341
|
progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
|
290
342
|
progress.isHidden = true
|
|
@@ -329,11 +381,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
329
381
|
|
|
330
382
|
private func createToolNode(systemName: String, labelText: String, onTap: @escaping () -> Void) -> ToolNode {
|
|
331
383
|
let icon = UIImageView(image: UIImage(systemName: systemName))
|
|
332
|
-
icon.tintColor = DesignTokens.
|
|
384
|
+
icon.tintColor = DesignTokens.textSecondary
|
|
333
385
|
icon.contentMode = .scaleAspectFit
|
|
334
386
|
let tile = UIView()
|
|
335
|
-
tile.backgroundColor =
|
|
336
|
-
tile.layer.cornerRadius = DesignTokens.
|
|
387
|
+
tile.backgroundColor = .clear
|
|
388
|
+
tile.layer.cornerRadius = DesignTokens.touchTargetMin / 2
|
|
337
389
|
tile.addSubview(icon)
|
|
338
390
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
339
391
|
NSLayoutConstraint.activate([
|
|
@@ -344,9 +396,13 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
344
396
|
])
|
|
345
397
|
let label = UILabel()
|
|
346
398
|
label.text = labelText
|
|
347
|
-
label.font = .systemFont(ofSize: 11)
|
|
348
|
-
label.textColor = DesignTokens.
|
|
399
|
+
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
400
|
+
label.textColor = DesignTokens.textSecondary
|
|
349
401
|
label.textAlignment = .center
|
|
402
|
+
// Labels like "Stickers" must never wrap to a second line.
|
|
403
|
+
label.numberOfLines = 1
|
|
404
|
+
label.adjustsFontSizeToFitWidth = true
|
|
405
|
+
label.minimumScaleFactor = 0.85
|
|
350
406
|
let root = UIStackView(arrangedSubviews: [tile, label])
|
|
351
407
|
root.axis = .vertical
|
|
352
408
|
root.alignment = .center
|
|
@@ -355,6 +411,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
355
411
|
toolNodeActions[ObjectIdentifier(root)] = onTap
|
|
356
412
|
root.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleToolNodeTap(_:))))
|
|
357
413
|
root.applyPressScale()
|
|
414
|
+
root.isAccessibilityElement = true
|
|
415
|
+
root.accessibilityLabel = labelText
|
|
416
|
+
root.accessibilityTraits = .button
|
|
358
417
|
NSLayoutConstraint.activate([
|
|
359
418
|
tile.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
|
|
360
419
|
tile.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
|
|
@@ -362,10 +421,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
362
421
|
return ToolNode(root: root, tile: tile, icon: icon, label: label)
|
|
363
422
|
}
|
|
364
423
|
|
|
424
|
+
/// Selection is a purple icon + label over a faint purple disc — not a filled tile, which at dock
|
|
425
|
+
/// size reads as a large button and fights the photo for attention.
|
|
365
426
|
private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
|
|
366
|
-
|
|
367
|
-
node.
|
|
368
|
-
node.
|
|
427
|
+
let accent = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
428
|
+
node.root.accessibilityTraits = selected ? [.button, .selected] : .button
|
|
429
|
+
node.tile.backgroundColor = selected ? accent.withAlphaComponent(0.18) : .clear
|
|
430
|
+
node.icon.tintColor = selected ? accent : DesignTokens.textSecondary
|
|
431
|
+
node.label.textColor = selected ? accent : DesignTokens.textSecondary
|
|
369
432
|
}
|
|
370
433
|
|
|
371
434
|
/// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
|
|
@@ -377,19 +440,32 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
377
440
|
("filters", "Filters", "camera.filters"),
|
|
378
441
|
("text", "Text", "textformat"),
|
|
379
442
|
("stickers", "Stickers", "face.smiling"),
|
|
380
|
-
("
|
|
381
|
-
("retouch", "Retouch", "wand.and.rays"),
|
|
382
|
-
("layers", "Layers", "square.3.layers.3d"),
|
|
443
|
+
("draw", "Draw", "pencil.tip"),
|
|
383
444
|
("resize", "Resize", "aspectratio"),
|
|
384
445
|
]
|
|
385
446
|
let scroll = UIScrollView(); scroll.backgroundColor = toolbarColor
|
|
447
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
386
448
|
photoToolScroll = scroll
|
|
387
|
-
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing =
|
|
449
|
+
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = DesignTokens.spaceXs
|
|
450
|
+
// Width that shows about five tools at once, so the dock fills the screen evenly on a small
|
|
451
|
+
// phone and does not leave large gaps on a large one.
|
|
452
|
+
let itemWidth = Swift.min(Swift.max(UIScreen.main.bounds.width / 5, 64), 88)
|
|
388
453
|
tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
|
|
389
454
|
let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
|
|
455
|
+
node.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
390
456
|
photoToolNodes[key] = node
|
|
391
457
|
stack.addArrangedSubview(node.root)
|
|
392
458
|
}
|
|
459
|
+
// Built with the same node factory as the other seven so the dock has one visual language;
|
|
460
|
+
// a zero-duration long press gives it the press-and-hold behaviour a tap gesture cannot.
|
|
461
|
+
let compare = createToolNode(systemName: "rectangle.split.2x1", labelText: "Original") {}
|
|
462
|
+
compare.root.accessibilityLabel = "Hold to compare with original"
|
|
463
|
+
compare.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
464
|
+
let hold = UILongPressGestureRecognizer(target: self, action: #selector(compareHeld(_:)))
|
|
465
|
+
hold.minimumPressDuration = 0
|
|
466
|
+
compare.root.addGestureRecognizer(hold)
|
|
467
|
+
photoToolNodes["compare"] = compare
|
|
468
|
+
stack.addArrangedSubview(compare.root)
|
|
393
469
|
scroll.addSubview(stack)
|
|
394
470
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
395
471
|
NSLayoutConstraint.activate([
|
|
@@ -403,66 +479,24 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
403
479
|
}
|
|
404
480
|
|
|
405
481
|
private func makeCropSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
406
|
-
let
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
}
|
|
482
|
+
let panel = CropPanel(accent:primaryColor,surface:toolbarColor,foreground:textColor,
|
|
483
|
+
originalRatio: { session.baseImage.map { $0.size.width/$0.size.height } ?? 1 },
|
|
484
|
+
onReset: { [weak self] in self?.resetPhotoEdits() },
|
|
485
|
+
onDone: { [weak self] in self?.setCropMode(false) },
|
|
486
|
+
onCancel: { [weak self] in self?.cancelCropMode() },
|
|
487
|
+
onStraighten: { [weak self] value in
|
|
488
|
+
session.update { $0.setStraighten(value) }; self?.photoCropPanel?.sync(session.state); self?.schedulePhotoPreviewRender()
|
|
420
489
|
},
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
session.
|
|
424
|
-
self.schedulePhotoPreviewRender()
|
|
425
|
-
DispatchQueue.main.async {
|
|
426
|
-
imageView.resetToFit()
|
|
427
|
-
if self.cropMode { self.cropOverlay?.setImageBounds(imageView.currentImageBounds(), resetCrop: true) }
|
|
428
|
-
}
|
|
490
|
+
onRotate: { [weak self] in
|
|
491
|
+
session.update { $0.rotateRight(); $0.zoom = 1; $0.panX = 0; $0.panY = 0; $0.aspectRatio = nil; $0.aspectPreset = "Free" }
|
|
492
|
+
self?.photoCropPanel?.sync(session.state); self?.schedulePhotoPreviewRender()
|
|
429
493
|
},
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
494
|
+
onRatio: { [weak self] label,ratio in
|
|
495
|
+
session.update { $0.aspectRatio = ratio; $0.aspectPreset = label }
|
|
496
|
+
self?.cropOverlay?.setAspectRatio(ratio); self?.photoCropPanel?.sync(session.state)
|
|
497
|
+
})
|
|
498
|
+
photoCropPanel = panel; panel.sync(session.state)
|
|
499
|
+
return panel
|
|
466
500
|
}
|
|
467
501
|
|
|
468
502
|
private func onPhotoToolTapped(_ key: String) {
|
|
@@ -470,19 +504,20 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
470
504
|
switch key {
|
|
471
505
|
case "crop":
|
|
472
506
|
setCropMode(true)
|
|
473
|
-
case "adjust"
|
|
507
|
+
case "adjust":
|
|
508
|
+
setAdjustMode(true, session: session)
|
|
509
|
+
case "filters":
|
|
474
510
|
setFiltersMode(true, session: session)
|
|
475
511
|
case "text":
|
|
476
512
|
showTextInputDialog(session: session)
|
|
477
513
|
case "stickers":
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
514
|
+
stickersMode = true
|
|
515
|
+
refreshPhotoToolSelection()
|
|
516
|
+
presentOnlineStickerSheet(purpose: .photoSticker)
|
|
517
|
+
case "draw":
|
|
518
|
+
setDrawMode(true, session: session)
|
|
481
519
|
case "resize":
|
|
482
520
|
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
521
|
default:
|
|
487
522
|
showToast("\(key) is coming in a later milestone.")
|
|
488
523
|
}
|
|
@@ -491,24 +526,31 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
491
526
|
// MARK: - Layers: text, selection, and transform/duplicate/reorder/lock/hide/delete
|
|
492
527
|
// with undo/redo (Milestone 4)
|
|
493
528
|
|
|
494
|
-
private func showTextInputDialog(session: PhotoEditSession) {
|
|
495
|
-
let
|
|
496
|
-
|
|
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)
|
|
529
|
+
private func showTextInputDialog(session: PhotoEditSession, editingLayer: PhotoLayer? = nil) {
|
|
530
|
+
let sheet = TextEditorSheet(text: editingLayer?.text ?? "", editing: editingLayer != nil, accent: color("primaryColor") ?? DesignTokens.primaryContainer) { [weak self] text in
|
|
531
|
+
var layer = editingLayer ?? PhotoLayer(type: .text)
|
|
501
532
|
layer.text = text
|
|
502
|
-
session.layerStack.commit {
|
|
533
|
+
session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
|
|
503
534
|
self?.selectLayer(layer.id)
|
|
504
|
-
|
|
505
|
-
|
|
535
|
+
self?.onLayerStackChanged()
|
|
536
|
+
}
|
|
537
|
+
present(sheet, animated: true)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private func newSticker() -> PhotoLayer {
|
|
541
|
+
var layer = PhotoLayer(type: .sticker)
|
|
542
|
+
let bounds: CGRect
|
|
543
|
+
if let imageView = photoImageView { bounds = imageView.currentImageBounds() }
|
|
544
|
+
else if let session = videoSession, let overlay = videoOverlayImageView { bounds = computeVideoLetterboxBounds(session: session, in: overlay) }
|
|
545
|
+
else { bounds = CGRect(x: 0, y: 0, width: 1, height: 1) }
|
|
546
|
+
layer.scale = 0.3 * bounds.width / max(0.18 * min(bounds.width, bounds.height), 0.001)
|
|
547
|
+
return layer
|
|
506
548
|
}
|
|
507
549
|
|
|
508
550
|
private func setStickersMode(_ enabled: Bool) {
|
|
509
551
|
stickersMode = enabled
|
|
510
552
|
stickersSubBar?.isHidden = !enabled
|
|
511
|
-
mainToolBar?.isHidden = enabled
|
|
553
|
+
mainToolBar?.isHidden = enabled || selectedLayerID != nil
|
|
512
554
|
refreshPhotoToolSelection()
|
|
513
555
|
}
|
|
514
556
|
|
|
@@ -518,30 +560,28 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
518
560
|
let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
|
|
519
561
|
PhotoLayerRenderer.builtinStickerIDs().forEach { id in
|
|
520
562
|
chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
|
|
521
|
-
|
|
563
|
+
guard let self else { return }
|
|
564
|
+
var layer = self.newSticker()
|
|
522
565
|
layer.stickerId = id
|
|
523
566
|
session.layerStack.commit { $0 + [layer] }
|
|
524
|
-
self
|
|
525
|
-
self
|
|
567
|
+
self.selectLayer(layer.id)
|
|
568
|
+
self.setStickersMode(false)
|
|
526
569
|
})
|
|
527
570
|
}
|
|
528
571
|
consumerStickerAssets().forEach { asset in
|
|
529
572
|
chipStack.addArrangedSubview(button(asset.id, color: textColor) { [weak self] in
|
|
530
|
-
|
|
573
|
+
guard let self else { return }
|
|
574
|
+
var layer = self.newSticker()
|
|
531
575
|
layer.stickerUri = asset.uri
|
|
576
|
+
if let image = self.resolveVideoImageLayer(asset.uri) { layer.overlayAspectRatio = image.size.width / max(image.size.height, 1) }
|
|
532
577
|
session.layerStack.commit { $0 + [layer] }
|
|
533
|
-
self
|
|
534
|
-
self
|
|
578
|
+
self.selectLayer(layer.id)
|
|
579
|
+
self.setStickersMode(false)
|
|
535
580
|
})
|
|
536
581
|
}
|
|
537
582
|
chipStack.addArrangedSubview(button("Upload", color: textColor) { [weak self] in
|
|
538
583
|
self?.presentImagePicker(purpose: .photoSticker)
|
|
539
584
|
})
|
|
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
585
|
scroll.addSubview(chipStack)
|
|
546
586
|
chipStack.translatesAutoresizingMaskIntoConstraints = false
|
|
547
587
|
NSLayoutConstraint.activate([
|
|
@@ -559,6 +599,71 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
559
599
|
return bar
|
|
560
600
|
}
|
|
561
601
|
|
|
602
|
+
private func setDrawMode(_ enabled: Bool, session: PhotoEditSession) {
|
|
603
|
+
drawMode = enabled
|
|
604
|
+
drawOverlay?.isHidden = !enabled
|
|
605
|
+
drawSubBar?.isHidden = !enabled
|
|
606
|
+
mainToolBar?.isHidden = enabled
|
|
607
|
+
layerOverlay?.isHidden = enabled
|
|
608
|
+
photoImageView?.panZoomEnabled = !enabled
|
|
609
|
+
if enabled {
|
|
610
|
+
drawOverlay?.strokeColor = .red
|
|
611
|
+
drawOverlay?.strokeWidthPx = 4
|
|
612
|
+
if let bounds = photoImageView?.currentImageBounds() { drawOverlay?.setImageBounds(bounds) }
|
|
613
|
+
} else {
|
|
614
|
+
commitDrawStrokes(session: session)
|
|
615
|
+
drawOverlay?.clearStrokes()
|
|
616
|
+
}
|
|
617
|
+
refreshPhotoToolSelection()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private func commitDrawStrokes(session: PhotoEditSession) {
|
|
621
|
+
guard let drawOverlay, drawOverlay.hasStrokes, let bounds = photoImageView?.currentImageBounds(), bounds.width > 0 else { return }
|
|
622
|
+
let shortSide = min(bounds.width, bounds.height)
|
|
623
|
+
let newLayers: [PhotoLayer] = drawOverlay.normalizedStrokes().compactMap { points, stroke in
|
|
624
|
+
guard points.count >= 2 else { return nil }
|
|
625
|
+
let centerX = points.reduce(0) { $0 + $1.0 } / CGFloat(points.count)
|
|
626
|
+
let centerY = points.reduce(0) { $0 + $1.1 } / CGFloat(points.count)
|
|
627
|
+
var layer = PhotoLayer(type: .drawing)
|
|
628
|
+
layer.x = centerX; layer.y = centerY
|
|
629
|
+
layer.drawColor = stroke.color
|
|
630
|
+
layer.drawStrokeWidth = stroke.widthPx / shortSide
|
|
631
|
+
layer.drawPoints = points.map { ($0.0 - centerX, $0.1 - centerY) }
|
|
632
|
+
return layer
|
|
633
|
+
}
|
|
634
|
+
if !newLayers.isEmpty { session.layerStack.commit { $0 + newLayers } }
|
|
635
|
+
onLayerStackChanged()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private func makeDrawSubBar(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
639
|
+
let colors: [(String, UIColor)] = [("Red", .red), ("Yellow", .yellow), ("Green", .green), ("Blue", .systemBlue), ("White", .white), ("Black", .black)]
|
|
640
|
+
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
641
|
+
let scroll = UIScrollView()
|
|
642
|
+
let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 4
|
|
643
|
+
colors.forEach { label, value in controls.addArrangedSubview(button(label, color: value) { [weak self] in self?.drawOverlay?.strokeColor = value }) }
|
|
644
|
+
controls.addArrangedSubview(button("Thin", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 2 })
|
|
645
|
+
controls.addArrangedSubview(button("Thick", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 10 })
|
|
646
|
+
controls.addArrangedSubview(button("Undo", color: textColor) { [weak self] in self?.drawOverlay?.undoLastStroke() })
|
|
647
|
+
controls.addArrangedSubview(button("Clear", color: textColor) { [weak self] in self?.drawOverlay?.clearStrokes() })
|
|
648
|
+
scroll.addSubview(controls); controls.translatesAutoresizingMaskIntoConstraints = false
|
|
649
|
+
NSLayoutConstraint.activate([
|
|
650
|
+
controls.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
651
|
+
controls.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
|
|
652
|
+
controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
653
|
+
controls.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
654
|
+
controls.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
655
|
+
])
|
|
656
|
+
bar.addArrangedSubview(scroll)
|
|
657
|
+
bar.addArrangedSubview(button("Done", color: primaryColor) { [weak self] in
|
|
658
|
+
guard let self, let session = self.photoSession else { return }
|
|
659
|
+
self.setDrawMode(false, session: session)
|
|
660
|
+
})
|
|
661
|
+
bar.backgroundColor = toolbarColor
|
|
662
|
+
bar.isLayoutMarginsRelativeArrangement = true
|
|
663
|
+
bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
|
664
|
+
return bar
|
|
665
|
+
}
|
|
666
|
+
|
|
562
667
|
private func consumerStickerAssets() -> [(id: String, uri: String)] {
|
|
563
668
|
stickerAssets.compactMap { asset in
|
|
564
669
|
guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
|
|
@@ -578,14 +683,24 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
578
683
|
present(picker, animated: true)
|
|
579
684
|
}
|
|
580
685
|
|
|
581
|
-
/// Presents the
|
|
582
|
-
/// insertion point once the user picks a sticker — same shared path the Upload flow uses.
|
|
686
|
+
/// Presents the bundled sticker sheet, reusing `handlePickedImage` as the insertion point.
|
|
583
687
|
private func presentOnlineStickerSheet(purpose: ImagePickPurpose) {
|
|
584
|
-
let
|
|
688
|
+
let runtimeStickers = consumerStickerAssets().map { OnlineStickerSheetViewController.RuntimeSticker(id: $0.id, uri: $0.uri) }
|
|
689
|
+
let sheet = OnlineStickerSheetViewController(runtimeStickers: runtimeStickers)
|
|
690
|
+
sheet.isModalInPresentation = false
|
|
691
|
+
sheet.onDismiss = { [weak self] in
|
|
692
|
+
if case .photoSticker = purpose {
|
|
693
|
+
self?.stickersMode = false
|
|
694
|
+
self?.refreshPhotoToolSelection()
|
|
695
|
+
}
|
|
696
|
+
}
|
|
585
697
|
sheet.onStickerPicked = { [weak self] fileURL, image in
|
|
586
698
|
self?.handlePickedImage(fileURL, image: image, purpose: purpose)
|
|
587
699
|
}
|
|
588
700
|
sheet.sheetPresentationController?.detents = [.medium(), .large()]
|
|
701
|
+
sheet.sheetPresentationController?.selectedDetentIdentifier = .large
|
|
702
|
+
sheet.sheetPresentationController?.prefersGrabberVisible = true
|
|
703
|
+
sheet.sheetPresentationController?.preferredCornerRadius = DesignTokens.radiusSheet
|
|
589
704
|
present(sheet, animated: true)
|
|
590
705
|
}
|
|
591
706
|
|
|
@@ -608,8 +723,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
608
723
|
switch purpose {
|
|
609
724
|
case .photoSticker:
|
|
610
725
|
guard let session = photoSession else { return }
|
|
611
|
-
var layer =
|
|
726
|
+
var layer = newSticker()
|
|
612
727
|
layer.stickerUri = uri
|
|
728
|
+
layer.overlayAspectRatio = aspectRatio
|
|
613
729
|
session.layerStack.commit { $0 + [layer] }
|
|
614
730
|
selectLayer(layer.id)
|
|
615
731
|
setStickersMode(false)
|
|
@@ -623,8 +739,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
623
739
|
selectLayer(layer.id)
|
|
624
740
|
case .videoSticker:
|
|
625
741
|
guard let session = videoSession else { return }
|
|
626
|
-
var layer =
|
|
742
|
+
var layer = newSticker()
|
|
627
743
|
layer.stickerUri = uri
|
|
744
|
+
layer.overlayAspectRatio = aspectRatio
|
|
628
745
|
session.layerStack.commit { $0 + [layer] }
|
|
629
746
|
onVideoLayerStackChanged()
|
|
630
747
|
selectVideoLayer(layer.id, session: session)
|
|
@@ -646,8 +763,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
646
763
|
let selected: Bool
|
|
647
764
|
switch key {
|
|
648
765
|
case "crop": selected = cropMode
|
|
649
|
-
case "adjust"
|
|
766
|
+
case "adjust": selected = adjustMode
|
|
767
|
+
case "filters": selected = filtersMode
|
|
650
768
|
case "stickers": selected = stickersMode
|
|
769
|
+
case "draw": selected = drawMode
|
|
651
770
|
default: selected = false
|
|
652
771
|
}
|
|
653
772
|
setToolNodeSelected(node, selected: selected, textColor: textColor)
|
|
@@ -665,46 +784,35 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
665
784
|
}
|
|
666
785
|
|
|
667
786
|
private func selectLayer(_ id: String?) {
|
|
787
|
+
if selectedLayerID != id { activeLayerPropertyKey = "" }
|
|
668
788
|
selectedLayerID = id
|
|
669
789
|
layerOverlay?.selectedLayerID = id
|
|
670
790
|
if let session = photoSession {
|
|
671
791
|
layerOverlay?.layers = session.layerStack.layers
|
|
672
792
|
schedulePhotoPreviewRender()
|
|
673
793
|
}
|
|
794
|
+
if id != nil {
|
|
795
|
+
if drawMode, let session = photoSession { setDrawMode(false, session: session) }
|
|
796
|
+
if cropMode { cancelCropMode() }
|
|
797
|
+
filtersMode = false; stickersMode = false; adjustMode = false
|
|
798
|
+
filtersSubBar?.isHidden = true; stickersSubBar?.isHidden = true; adjustSubBar?.isHidden = true
|
|
799
|
+
}
|
|
674
800
|
setLayerToolBarVisible(id != nil)
|
|
675
801
|
}
|
|
676
802
|
|
|
677
803
|
private func setLayerToolBarVisible(_ visible: Bool) {
|
|
678
804
|
layerToolBar?.isHidden = !visible
|
|
679
805
|
mainToolBar?.isHidden = visible
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
}
|
|
806
|
+
let isText = currentSelectedLayer()?.type == .text
|
|
807
|
+
let simple = isText || currentSelectedLayer()?.type == .sticker
|
|
808
|
+
["scale", "lock", "visibility", "front", "back"].forEach { layerPropertyButtons[$0]?.isHidden = simple }
|
|
809
|
+
["edit", "color", "fontSize"].forEach { layerPropertyButtons[$0]?.isHidden = !isText }
|
|
810
|
+
if !isText && ["color", "fontSize"].contains(activeLayerPropertyKey) { activeLayerPropertyKey = "" }
|
|
811
|
+
layerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeLayerPropertyKey)
|
|
812
|
+
layerColorSwatchRow?.isHidden = !visible || activeLayerPropertyKey != "color"
|
|
813
|
+
if visible { syncLayerPropertySlider() }
|
|
814
|
+
refreshContextSelection(layerPropertyButtons, active: activeLayerPropertyKey)
|
|
815
|
+
updateSwatches(textSwatches, selected: currentSelectedLayer()?.textColor)
|
|
708
816
|
}
|
|
709
817
|
|
|
710
818
|
private func currentSelectedLayer() -> PhotoLayer? {
|
|
@@ -712,14 +820,30 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
712
820
|
return session.layerStack.layers.first { $0.id == selectedLayerID }
|
|
713
821
|
}
|
|
714
822
|
|
|
823
|
+
private func configureLayerSlider(_ slider: UISlider, video: Bool) {
|
|
824
|
+
slider.minimumTrackTintColor = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
825
|
+
slider.addAction(UIAction { [weak self] _ in
|
|
826
|
+
guard let self else { return }
|
|
827
|
+
self.sliderSnapshot = video ? self.videoSession?.layerStack.layers : self.photoSession?.layerStack.layers
|
|
828
|
+
}, for: .touchDown)
|
|
829
|
+
slider.addAction(UIAction { [weak self] _ in
|
|
830
|
+
guard let self, let snapshot = self.sliderSnapshot else { return }
|
|
831
|
+
if video { self.videoSession?.layerStack.commitSnapshot(snapshot); self.onVideoLayerStackChanged() }
|
|
832
|
+
else { self.photoSession?.layerStack.commitSnapshot(snapshot); self.onLayerStackChanged() }
|
|
833
|
+
self.sliderSnapshot = nil
|
|
834
|
+
}, for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
|
835
|
+
}
|
|
836
|
+
|
|
715
837
|
private func syncLayerPropertySlider() {
|
|
716
838
|
guard let slider = layerPropertySlider, let layer = currentSelectedLayer() else { return }
|
|
717
839
|
let (min, max, value): (CGFloat, CGFloat, CGFloat)
|
|
718
840
|
switch activeLayerPropertyKey {
|
|
719
|
-
case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
|
|
841
|
+
case "rotation": (min, max, value) = (-180, 180, (layer.rotationDegrees.truncatingRemainder(dividingBy: 360) + 540).truncatingRemainder(dividingBy: 360) - 180)
|
|
720
842
|
case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
|
|
843
|
+
case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
|
|
721
844
|
default: (min, max, value) = (20, 800, layer.scale * 100)
|
|
722
845
|
}
|
|
846
|
+
(slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
|
|
723
847
|
slider.minimumValue = Float(min)
|
|
724
848
|
slider.maximumValue = Float(max)
|
|
725
849
|
slider.value = Float(value)
|
|
@@ -727,6 +851,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
727
851
|
|
|
728
852
|
@objc private func layerPropertyChanged(_ slider: UISlider) {
|
|
729
853
|
guard let session = photoSession, let id = selectedLayerID else { return }
|
|
854
|
+
slider.setNeedsDisplay()
|
|
730
855
|
let value = CGFloat(slider.value)
|
|
731
856
|
let key = activeLayerPropertyKey
|
|
732
857
|
session.layerStack.updateLive { list in
|
|
@@ -736,6 +861,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
736
861
|
switch key {
|
|
737
862
|
case "rotation": updated.rotationDegrees = value
|
|
738
863
|
case "opacity": updated.opacity = value / 100
|
|
864
|
+
case "fontSize": updated.fontSize = value
|
|
739
865
|
default: updated.scale = value / 100
|
|
740
866
|
}
|
|
741
867
|
return updated
|
|
@@ -746,99 +872,100 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
746
872
|
}
|
|
747
873
|
|
|
748
874
|
private func makeLayerToolBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
self
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
875
|
+
makeContextToolbar(video: false, toolbarColor: toolbarColor) { [weak self] key in
|
|
876
|
+
guard let self, let layer = self.currentSelectedLayer() else { return }
|
|
877
|
+
switch key {
|
|
878
|
+
case "edit": self.showTextInputDialog(session: session, editingLayer: layer)
|
|
879
|
+
case "done": self.selectLayer(nil)
|
|
880
|
+
case "delete":
|
|
881
|
+
session.layerStack.commit { $0.filter { $0.id != layer.id } }
|
|
882
|
+
self.selectLayer(nil); self.onLayerStackChanged()
|
|
883
|
+
case "duplicate":
|
|
884
|
+
let copy = layer.duplicated()
|
|
885
|
+
session.layerStack.commit { $0 + [copy] }; self.selectLayer(copy.id); self.onLayerStackChanged()
|
|
886
|
+
case "lock", "visibility", "front", "back":
|
|
887
|
+
session.layerStack.commit { list in
|
|
888
|
+
if key == "front" { return list.filter { $0.id != layer.id } + [layer] }
|
|
889
|
+
if key == "back" { return [layer] + list.filter { $0.id != layer.id } }
|
|
890
|
+
return list.map { item in
|
|
891
|
+
guard item.id == layer.id else { return item }
|
|
892
|
+
var updated = item
|
|
893
|
+
if key == "lock" { updated.locked.toggle() } else { updated.visible.toggle() }
|
|
894
|
+
return updated
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
self.onLayerStackChanged()
|
|
898
|
+
default:
|
|
899
|
+
self.activeLayerPropertyKey = self.activeLayerPropertyKey == key ? "" : key
|
|
900
|
+
self.setLayerToolBarVisible(true)
|
|
761
901
|
}
|
|
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
902
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private func toolButton(_ label: String, symbol: String, action: @escaping () -> Void) -> UIButton {
|
|
906
|
+
let control = UIButton(type: .system)
|
|
907
|
+
var config = UIButton.Configuration.plain()
|
|
908
|
+
config.title = label
|
|
909
|
+
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
|
|
910
|
+
config.imagePlacement = .top
|
|
911
|
+
config.imagePadding = 6
|
|
912
|
+
config.baseForegroundColor = DesignTokens.outline
|
|
913
|
+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
914
|
+
var value = incoming; value.font = .systemFont(ofSize: 11, weight: .medium); return value
|
|
802
915
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
916
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4)
|
|
917
|
+
control.configuration = config
|
|
918
|
+
control.accessibilityLabel = label
|
|
919
|
+
let width = control.widthAnchor.constraint(equalToConstant: 68); width.priority = .defaultHigh; width.isActive = true
|
|
920
|
+
control.heightAnchor.constraint(equalToConstant: 72).isActive = true
|
|
921
|
+
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
922
|
+
control.applyPressScale()
|
|
923
|
+
return control
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private func refreshContextSelection(_ buttons: [String: UIButton], active: String) {
|
|
927
|
+
buttons.forEach { key, control in
|
|
928
|
+
let selected = key == active
|
|
929
|
+
control.configuration?.baseForegroundColor = key == "delete" ? .systemRed : (selected ? (color("primaryColor") ?? DesignTokens.primaryContainer) : DesignTokens.outline)
|
|
930
|
+
control.backgroundColor = selected ? DesignTokens.surfaceContainerHigh : .clear
|
|
931
|
+
control.layer.cornerRadius = 12
|
|
932
|
+
control.accessibilityTraits = selected ? [.button, .selected] : .button
|
|
809
933
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private func makeContextToolbar(video: Bool, toolbarColor: UIColor, action: @escaping (String) -> Void) -> UIView {
|
|
937
|
+
var actions = [("edit", "Edit", "pencil"), ("color", "Color", "paintpalette"), ("fontSize", "Size", "textformat.size"), ("rotation", "Rotate", "arrow.clockwise"), ("opacity", "Opacity", "circle.lefthalf.filled"), ("scale", "Scale", "arrow.up.left.and.arrow.down.right"), ("duplicate", "Duplicate", "square.on.square"), ("delete", "Delete", "trash")]
|
|
938
|
+
if !video { actions += [("lock", "Lock", "lock"), ("visibility", "Hide", "eye.slash"), ("front", "Front", "square.3.layers.3d.top.filled"), ("back", "Back", "square.3.layers.3d.bottom.filled")] }
|
|
939
|
+
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
940
|
+
let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false
|
|
941
|
+
let stack = UIStackView(); stack.axis = .horizontal
|
|
942
|
+
actions.forEach { key, label, symbol in
|
|
943
|
+
let control = toolButton(label, symbol: symbol) { action(key) }
|
|
944
|
+
if video { videoLayerPropertyButtons[key] = control } else { layerPropertyButtons[key] = control }
|
|
945
|
+
stack.addArrangedSubview(control)
|
|
946
|
+
}
|
|
947
|
+
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
820
948
|
NSLayoutConstraint.activate([
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
949
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
950
|
+
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
|
|
951
|
+
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
952
|
+
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
953
|
+
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
954
|
+
scroll.heightAnchor.constraint(equalToConstant: 72),
|
|
826
955
|
])
|
|
827
956
|
bar.addArrangedSubview(scroll)
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
bar.
|
|
831
|
-
bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
|
957
|
+
let done = toolButton("Done", symbol: "checkmark") { action("done") }
|
|
958
|
+
done.configuration?.baseForegroundColor = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
959
|
+
bar.addArrangedSubview(done); bar.backgroundColor = toolbarColor
|
|
832
960
|
return bar
|
|
833
961
|
}
|
|
834
962
|
|
|
835
|
-
/// Circular color swatch, matching the Android drawing_brush_studio mockup's palette dots.
|
|
836
963
|
private func createColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIView {
|
|
837
964
|
let outer = UIButton(type: .custom)
|
|
838
965
|
outer.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
839
|
-
outer.layer.cornerRadius =
|
|
840
|
-
outer.widthAnchor.constraint(equalToConstant:
|
|
841
|
-
outer.heightAnchor.constraint(equalToConstant:
|
|
966
|
+
outer.layer.cornerRadius = 22
|
|
967
|
+
outer.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
968
|
+
outer.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
842
969
|
let inner = UIView()
|
|
843
970
|
inner.backgroundColor = color
|
|
844
971
|
inner.layer.cornerRadius = 12
|
|
@@ -857,41 +984,45 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
857
984
|
|
|
858
985
|
/// Text color swatch row, shown when a TEXT layer's "Colors" property is active.
|
|
859
986
|
private func makeTextColorSwatchRow(session: PhotoEditSession) -> UIView {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
self.onLayerStackChanged()
|
|
875
|
-
})
|
|
987
|
+
makeTextPalette(video: false) { [weak self] color in
|
|
988
|
+
guard let self, let id = self.selectedLayerID else { return }
|
|
989
|
+
session.layerStack.commit { list in list.map { item in
|
|
990
|
+
var updated = item; if item.id == id { updated.textColor = color }; return updated
|
|
991
|
+
} }
|
|
992
|
+
self.onLayerStackChanged(); self.setLayerToolBarVisible(true)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private func updateSwatches(_ swatches: [(UIColor, UIView)], selected: UIColor?) {
|
|
997
|
+
swatches.forEach { color, swatch in
|
|
998
|
+
swatch.layer.borderWidth = color == selected ? 2 : 0
|
|
999
|
+
swatch.layer.borderColor = (self.color("primaryColor") ?? DesignTokens.primaryContainer).cgColor
|
|
1000
|
+
swatch.accessibilityTraits = color == selected ? [.button, .selected] : .button
|
|
876
1001
|
}
|
|
877
|
-
|
|
878
|
-
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private func makeTextPalette(video: Bool, onColor: @escaping (UIColor) -> Void) -> UIView {
|
|
1005
|
+
let scroll = UIScrollView(); scroll.showsHorizontalScrollIndicator = false
|
|
1006
|
+
scroll.backgroundColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
|
|
1007
|
+
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 4
|
|
1008
|
+
let colors: [(String, UIColor)] = [("White", .white), ("Black", .black), ("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow), ("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", DesignTokens.primary), ("Pink", .systemPink)]
|
|
1009
|
+
colors.forEach { name, color in
|
|
1010
|
+
let swatch = createColorSwatch(color: color) { onColor(color) }
|
|
1011
|
+
swatch.accessibilityLabel = name
|
|
1012
|
+
if video { videoTextSwatches.append((color, swatch)) } else { textSwatches.append((color, swatch)) }
|
|
1013
|
+
stack.addArrangedSubview(swatch)
|
|
1014
|
+
}
|
|
1015
|
+
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
879
1016
|
NSLayoutConstraint.activate([
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1017
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
1018
|
+
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
|
|
1019
|
+
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
1020
|
+
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
1021
|
+
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
885
1022
|
])
|
|
886
|
-
|
|
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
|
|
1023
|
+
return scroll
|
|
892
1024
|
}
|
|
893
1025
|
|
|
894
|
-
/// Refreshes the preview + selection + undo/redo enabled state after any layer-stack change.
|
|
895
1026
|
private func onLayerStackChanged() {
|
|
896
1027
|
guard let session = photoSession else { return }
|
|
897
1028
|
if let id = selectedLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
|
|
@@ -907,120 +1038,245 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
907
1038
|
if selectedLayerID == nil, layerToolBar?.isHidden == false { setLayerToolBarVisible(false) }
|
|
908
1039
|
}
|
|
909
1040
|
|
|
1041
|
+
private func setAdjustMode(_ enabled: Bool, session: PhotoEditSession) {
|
|
1042
|
+
adjustMode = enabled
|
|
1043
|
+
// The slider lives inside the panel now, so showing the panel shows it.
|
|
1044
|
+
adjustSubBar?.isHidden = !enabled
|
|
1045
|
+
mainToolBar?.isHidden = enabled
|
|
1046
|
+
layerOverlay?.isHidden = enabled
|
|
1047
|
+
// Entering Adjust selects a control to scrub but never resets the values themselves, so
|
|
1048
|
+
// Adjust -> Filters -> Adjust round-trips keep whatever the user already dialled in.
|
|
1049
|
+
if enabled {
|
|
1050
|
+
activeAdjustmentKey = "brightness"
|
|
1051
|
+
syncAdjustmentSlider(session: session)
|
|
1052
|
+
if let adjustSubBar { fadeInPanel(adjustSubBar) }
|
|
1053
|
+
}
|
|
1054
|
+
refreshPhotoToolSelection()
|
|
1055
|
+
refreshAdjustSelection(session: session)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
910
1058
|
private func setFiltersMode(_ enabled: Bool, session: PhotoEditSession) {
|
|
911
1059
|
filtersMode = enabled
|
|
912
1060
|
filtersSubBar?.isHidden = !enabled
|
|
913
|
-
adjustmentSlider?.isHidden = !enabled
|
|
914
1061
|
mainToolBar?.isHidden = enabled
|
|
915
1062
|
layerOverlay?.isHidden = enabled
|
|
916
|
-
if enabled {
|
|
1063
|
+
if enabled {
|
|
1064
|
+
syncAdjustmentSlider(session: session)
|
|
1065
|
+
if let filtersSubBar { fadeInPanel(filtersSubBar) }
|
|
1066
|
+
}
|
|
917
1067
|
refreshPhotoToolSelection()
|
|
918
1068
|
refreshFiltersSelection(session: session)
|
|
919
1069
|
}
|
|
920
1070
|
|
|
1071
|
+
/// Short cross-fade when a panel opens, per the "subtle transition" spec (no slide/scale drama).
|
|
1072
|
+
private func fadeInPanel(_ panel: UIView) {
|
|
1073
|
+
panel.alpha = 0
|
|
1074
|
+
UIView.animate(withDuration: 0.18) { panel.alpha = 1 }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private func refreshAdjustSelection(session: PhotoEditSession) {
|
|
1078
|
+
let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
1079
|
+
let defaults = PhotoAdjustments()
|
|
1080
|
+
adjustmentStripItems.forEach { key, item in
|
|
1081
|
+
if key == "mirror" {
|
|
1082
|
+
// A toggle, so "selected" means "on" rather than "being scrubbed".
|
|
1083
|
+
item.setSelected(session.adjustments.mirror, accentColor: primaryColor)
|
|
1084
|
+
item.setModified(false, accentColor: primaryColor)
|
|
1085
|
+
} else {
|
|
1086
|
+
item.setSelected(key == activeAdjustmentKey, accentColor: primaryColor)
|
|
1087
|
+
item.setModified(session.adjustments.value(key) != defaults.value(key), accentColor: primaryColor)
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
921
1092
|
private func refreshFiltersSelection(session: PhotoEditSession) {
|
|
922
|
-
let textColor = color("textColor") ?? DesignTokens.onSurface
|
|
923
1093
|
let primaryColor = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
924
|
-
|
|
925
|
-
|
|
1094
|
+
let activePreset = session.adjustments.filterPreset ?? ""
|
|
1095
|
+
filterThumbnails.forEach { id, thumbnail in
|
|
1096
|
+
thumbnail.setSelected(id == activePreset, accentColor: primaryColor)
|
|
1097
|
+
}
|
|
1098
|
+
// Intensity only means something once a preset is applied.
|
|
1099
|
+
filterIntensityControl?.alpha = activePreset.isEmpty ? 0 : 1
|
|
1100
|
+
filterIntensityControl?.isUserInteractionEnabled = !activePreset.isEmpty
|
|
926
1101
|
}
|
|
927
1102
|
|
|
1103
|
+
/// Points whichever slider is on screen at the currently selected value.
|
|
928
1104
|
private func syncAdjustmentSlider(session: PhotoEditSession) {
|
|
929
|
-
guard let slider = adjustmentSlider else { return }
|
|
930
1105
|
let (min, max) = PhotoAdjustments.range(for: activeAdjustmentKey)
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1106
|
+
if let control = adjustmentSliderControl {
|
|
1107
|
+
control.slider.minimumValue = Float(min)
|
|
1108
|
+
control.slider.maximumValue = Float(max)
|
|
1109
|
+
control.slider.value = Float(session.adjustments.value(activeAdjustmentKey))
|
|
1110
|
+
control.titleText = photoAdjustmentDefinitions.first { $0.0 == activeAdjustmentKey }?.1
|
|
1111
|
+
control.valueText = formatAdjustmentValue(activeAdjustmentKey, session.adjustments.value(activeAdjustmentKey))
|
|
1112
|
+
// Bipolar adjustments get a centre tick at their neutral value; unipolar ones (blur) do not.
|
|
1113
|
+
control.neutralValue = min < 0 ? 0 : nil
|
|
1114
|
+
}
|
|
1115
|
+
if let intensity = filterIntensityControl {
|
|
1116
|
+
let (strengthMin, strengthMax) = PhotoAdjustments.range(for: "filterStrength")
|
|
1117
|
+
intensity.slider.minimumValue = Float(strengthMin)
|
|
1118
|
+
intensity.slider.maximumValue = Float(strengthMax)
|
|
1119
|
+
intensity.slider.value = Float(session.adjustments.filterStrength)
|
|
1120
|
+
intensity.valueText = "\(Int(session.adjustments.filterStrength.rounded()))"
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private func formatAdjustmentValue(_ key: String, _ value: Double) -> String {
|
|
1125
|
+
let rounded = Int(value.rounded())
|
|
1126
|
+
if key == "blurRadius" { return "\(rounded)" }
|
|
1127
|
+
return rounded > 0 ? "+\(rounded)" : "\(rounded)"
|
|
934
1128
|
}
|
|
935
1129
|
|
|
936
1130
|
@objc private func adjustmentChanged(_ slider: UISlider) {
|
|
937
|
-
guard let session = photoSession
|
|
1131
|
+
guard let session = photoSession else { return }
|
|
938
1132
|
let key = activeAdjustmentKey
|
|
939
1133
|
session.updateAdjustments { $0.setValue(key, Double(slider.value)) }
|
|
1134
|
+
adjustmentSliderControl?.valueText = formatAdjustmentValue(key, Double(slider.value))
|
|
1135
|
+
// Coalesced by the preview renderer, so dragging does not queue a render per pixel.
|
|
940
1136
|
schedulePhotoPreviewRender()
|
|
941
1137
|
}
|
|
942
1138
|
|
|
943
|
-
private func
|
|
944
|
-
session
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1139
|
+
@objc private func filterIntensityChanged(_ slider: UISlider) {
|
|
1140
|
+
guard let session = photoSession else { return }
|
|
1141
|
+
session.updateAdjustments { $0.filterStrength = Double(slider.value) }
|
|
1142
|
+
filterIntensityControl?.valueText = "\(Int(slider.value.rounded()))"
|
|
1143
|
+
schedulePhotoPreviewRender()
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/// Adjustments the processing engine actually implements, in carousel order. Deliberately not a
|
|
1147
|
+
/// wishlist: every entry maps to a real `PhotoAdjustments` field, so there are no controls that
|
|
1148
|
+
/// move a slider without changing the photo. (Highlights/shadows/tint/sharpness were removed from
|
|
1149
|
+
/// the engine in an earlier pass and would need that processing restored before they could return.)
|
|
1150
|
+
private var photoAdjustmentDefinitions: [(String, String, String)] {
|
|
1151
|
+
[
|
|
1152
|
+
("exposure", "Exposure", "plusminus.circle"),
|
|
1153
|
+
("brightness", "Brightness", "sun.max"),
|
|
1154
|
+
("contrast", "Contrast", "circle.lefthalf.filled"),
|
|
1155
|
+
("saturation", "Saturation", "drop"),
|
|
1156
|
+
("temperature", "Warmth", "thermometer"),
|
|
1157
|
+
("blurRadius", "Blur", "camera.filters"),
|
|
953
1158
|
]
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/// Adjust panel: `Adjust / Reset / Done` header, one shared slider bound to whichever adjustment
|
|
1162
|
+
/// is selected, then a compact scrollable strip. Replaces the row of plain text buttons that
|
|
1163
|
+
/// previously mixed adjustments, presets and actions into a single undifferentiated bar.
|
|
1164
|
+
private func makeAdjustSubBar(session: PhotoEditSession, primaryColor: UIColor) -> UIView {
|
|
1165
|
+
let header = editorPanelHeader(
|
|
1166
|
+
title: "Adjust",
|
|
1167
|
+
accentColor: primaryColor,
|
|
1168
|
+
secondaryLabel: "Reset",
|
|
1169
|
+
onSecondary: { [weak self] in
|
|
1170
|
+
session.updateAdjustments {
|
|
1171
|
+
// Preserves the chosen filter preset — Reset here means "reset adjustments".
|
|
1172
|
+
let preset = $0.filterPreset
|
|
1173
|
+
let strength = $0.filterStrength
|
|
1174
|
+
$0 = PhotoAdjustments()
|
|
1175
|
+
$0.filterPreset = preset
|
|
1176
|
+
$0.filterStrength = strength
|
|
1177
|
+
}
|
|
1178
|
+
self?.syncAdjustmentSlider(session: session)
|
|
1179
|
+
self?.schedulePhotoPreviewRender()
|
|
1180
|
+
self?.refreshAdjustSelection(session: session)
|
|
1181
|
+
},
|
|
1182
|
+
onDone: { [weak self] in self?.setAdjustMode(false, session: session) }
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
let strip = UIStackView()
|
|
1186
|
+
strip.spacing = DesignTokens.spaceXs
|
|
1187
|
+
photoAdjustmentDefinitions.forEach { key, label, symbol in
|
|
1188
|
+
let item = editorStripItem(systemName: symbol, labelText: label) { [weak self] in
|
|
959
1189
|
self?.activeAdjustmentKey = key
|
|
960
1190
|
self?.syncAdjustmentSlider(session: session)
|
|
961
|
-
|
|
1191
|
+
self?.refreshAdjustSelection(session: session)
|
|
962
1192
|
}
|
|
963
|
-
|
|
964
|
-
|
|
1193
|
+
adjustmentStripItems[key] = item
|
|
1194
|
+
strip.addArrangedSubview(item.root)
|
|
965
1195
|
}
|
|
966
|
-
|
|
1196
|
+
// Mirror is a toggle, not a slider target, but it lives in the same strip so the panel has one
|
|
1197
|
+
// visual language rather than a stray button.
|
|
1198
|
+
let mirror = editorStripItem(systemName: "flip.horizontal", labelText: "Mirror") { [weak self] in
|
|
967
1199
|
session.updateAdjustments { $0.mirror.toggle() }
|
|
968
1200
|
self?.schedulePhotoPreviewRender()
|
|
969
|
-
|
|
1201
|
+
self?.refreshAdjustSelection(session: session)
|
|
1202
|
+
}
|
|
1203
|
+
adjustmentStripItems["mirror"] = mirror
|
|
1204
|
+
strip.addArrangedSubview(mirror.root)
|
|
1205
|
+
|
|
1206
|
+
let panel = UIStackView(arrangedSubviews: [header, adjustmentSliderControl ?? UIView(), editorToolStrip(strip)])
|
|
1207
|
+
panel.axis = .vertical
|
|
1208
|
+
panel.backgroundColor = DesignTokens.editorSurface
|
|
1209
|
+
NSLayoutConstraint.activate([
|
|
1210
|
+
header.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.headerHeight),
|
|
1211
|
+
panel.arrangedSubviews[1].heightAnchor.constraint(equalToConstant: EditorPanelMetrics.sliderHeight),
|
|
1212
|
+
])
|
|
1213
|
+
return panel
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/// Filters panel: `Filters / Done` header, an intensity slider shown once a preset is applied,
|
|
1217
|
+
/// then the preset carousel. Same skeleton as Adjust so the two read as one system.
|
|
1218
|
+
private func makeFiltersSubBar(session: PhotoEditSession, primaryColor: UIColor) -> UIView {
|
|
1219
|
+
let header = editorPanelHeader(
|
|
1220
|
+
title: "Filters",
|
|
1221
|
+
accentColor: primaryColor,
|
|
1222
|
+
onDone: { [weak self] in self?.setFiltersMode(false, session: session) }
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
let strip = UIStackView()
|
|
1226
|
+
strip.spacing = DesignTokens.spaceSm
|
|
1227
|
+
let original = editorFilterThumbnail(labelText: "Original") { [weak self] in
|
|
1228
|
+
session.updateAdjustments { $0.filterPreset = nil; $0.filterStrength = 100 }
|
|
1229
|
+
self?.syncAdjustmentSlider(session: session)
|
|
1230
|
+
self?.schedulePhotoPreviewRender()
|
|
1231
|
+
self?.refreshFiltersSelection(session: session)
|
|
1232
|
+
}
|
|
1233
|
+
original.image.image = session.baseImage
|
|
1234
|
+
filterThumbnails[""] = original
|
|
1235
|
+
strip.addArrangedSubview(original.root)
|
|
1236
|
+
|
|
970
1237
|
PhotoFilterPresets.presetIDs.forEach { id in
|
|
971
|
-
let
|
|
1238
|
+
let thumbnail = editorFilterThumbnail(labelText: PhotoFilterPresets.label(for: id)) { [weak self] in
|
|
972
1239
|
session.updateAdjustments { $0.filterPreset = $0.filterPreset == id ? nil : id }
|
|
973
|
-
self?.activeAdjustmentKey = "filterStrength"
|
|
974
1240
|
self?.syncAdjustmentSlider(session: session)
|
|
975
1241
|
self?.schedulePhotoPreviewRender()
|
|
976
|
-
|
|
1242
|
+
self?.refreshFiltersSelection(session: session)
|
|
977
1243
|
}
|
|
978
|
-
|
|
979
|
-
|
|
1244
|
+
// Thumbnails render off a cached downscaled copy of the photo, never the full-resolution
|
|
1245
|
+
// image — a preset per look at full size would stall the panel on open.
|
|
1246
|
+
filterThumbnailLoader.load(source: session.baseImage, presetID: id, size: EditorPanelMetrics.thumbnailWidth) { image in
|
|
1247
|
+
thumbnail.image.image = image
|
|
1248
|
+
}
|
|
1249
|
+
filterThumbnails[id] = thumbnail
|
|
1250
|
+
strip.addArrangedSubview(thumbnail.root)
|
|
980
1251
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
if let self { self.refreshFiltersSelection(session: session) }
|
|
986
|
-
})
|
|
987
|
-
scroll.addSubview(chipStack)
|
|
988
|
-
chipStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1252
|
+
|
|
1253
|
+
let panel = UIStackView(arrangedSubviews: [header, filterIntensityControl ?? UIView(), editorToolStrip(strip)])
|
|
1254
|
+
panel.axis = .vertical
|
|
1255
|
+
panel.backgroundColor = DesignTokens.editorSurface
|
|
989
1256
|
NSLayoutConstraint.activate([
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
993
|
-
chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
994
|
-
chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
1257
|
+
header.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.headerHeight),
|
|
1258
|
+
panel.arrangedSubviews[1].heightAnchor.constraint(equalToConstant: EditorPanelMetrics.sliderHeight),
|
|
995
1259
|
])
|
|
996
|
-
|
|
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
|
|
1260
|
+
return panel
|
|
1002
1261
|
}
|
|
1003
1262
|
|
|
1004
1263
|
private func setCropMode(_ enabled: Bool) {
|
|
1005
|
-
guard let
|
|
1264
|
+
guard let session = photoSession else { return }
|
|
1265
|
+
if enabled && !cropMode { cropEntryState = session.state }
|
|
1006
1266
|
cropMode = enabled
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
}
|
|
1017
|
-
|
|
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)
|
|
1267
|
+
photoImageView?.panZoomEnabled = !enabled
|
|
1268
|
+
cropOverlay?.isHidden = !enabled; cropSubBar?.isHidden = !enabled
|
|
1269
|
+
mainToolBar?.isHidden = enabled; layerOverlay?.isHidden = enabled
|
|
1270
|
+
if !enabled { cropEntryState = nil }
|
|
1271
|
+
photoCropPanel?.sync(session.state); schedulePhotoPreviewRender(); refreshPhotoToolSelection()
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private func cancelCropMode() {
|
|
1275
|
+
guard let session = photoSession else { return }
|
|
1276
|
+
if let saved = cropEntryState { session.update { $0 = saved } }
|
|
1277
|
+
setCropMode(false)
|
|
1021
1278
|
}
|
|
1022
1279
|
|
|
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
1280
|
private func aspectRatioLabel(_ ratio: CGFloat?) -> String {
|
|
1025
1281
|
guard let ratio else { return "Free" }
|
|
1026
1282
|
let knownRatios: [(String, CGFloat)] = [("1:1", 1), ("4:5", 4 / 5), ("3:4", 3 / 4), ("9:16", 9 / 16), ("16:9", 16 / 9)]
|
|
@@ -1028,16 +1284,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1028
1284
|
}
|
|
1029
1285
|
|
|
1030
1286
|
private func resetPhotoEdits() {
|
|
1031
|
-
guard let
|
|
1287
|
+
guard let session = photoSession else { return }
|
|
1032
1288
|
session.update { $0.reset() }
|
|
1033
|
-
|
|
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)
|
|
1289
|
+
photoCropPanel?.sync(session.state); schedulePhotoPreviewRender()
|
|
1041
1290
|
}
|
|
1042
1291
|
|
|
1043
1292
|
@objc private func straightenChanged(_ slider: UISlider) {
|
|
@@ -1051,6 +1300,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1051
1300
|
completion?(.failure(code: "E_INTERNAL", message: "Nothing to export."))
|
|
1052
1301
|
return
|
|
1053
1302
|
}
|
|
1303
|
+
if drawMode { setDrawMode(false, session: session) }
|
|
1054
1304
|
progressOverlay?.isHidden = false
|
|
1055
1305
|
let state = session.state
|
|
1056
1306
|
let adjustments = session.adjustments
|
|
@@ -1086,9 +1336,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1086
1336
|
videoSession = session
|
|
1087
1337
|
|
|
1088
1338
|
let header = makeHeader(textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
|
|
1089
|
-
let preview = UIView(); preview.backgroundColor =
|
|
1339
|
+
let preview = UIView(); preview.backgroundColor = .black
|
|
1340
|
+
preview.clipsToBounds = true; videoPreviewContainer = preview
|
|
1090
1341
|
|
|
1091
1342
|
let playerController = AVPlayerViewController()
|
|
1343
|
+
cropPlayerView = playerController.view
|
|
1344
|
+
playerController.videoGravity = .resizeAspect
|
|
1092
1345
|
playerController.player = session.player
|
|
1093
1346
|
playerController.showsPlaybackControls = false
|
|
1094
1347
|
addChild(playerController)
|
|
@@ -1114,6 +1367,21 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1114
1367
|
overlayView.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
1115
1368
|
])
|
|
1116
1369
|
|
|
1370
|
+
let cropCanvas = CropOverlayView(); cropCanvas.isHidden = true
|
|
1371
|
+
videoCropOverlay = cropCanvas
|
|
1372
|
+
preview.addSubview(cropCanvas); cropCanvas.translatesAutoresizingMaskIntoConstraints = false
|
|
1373
|
+
NSLayoutConstraint.activate([
|
|
1374
|
+
cropCanvas.leadingAnchor.constraint(equalTo:preview.leadingAnchor),cropCanvas.trailingAnchor.constraint(equalTo:preview.trailingAnchor),
|
|
1375
|
+
cropCanvas.topAnchor.constraint(equalTo:preview.topAnchor),cropCanvas.bottomAnchor.constraint(equalTo:preview.bottomAnchor)
|
|
1376
|
+
])
|
|
1377
|
+
cropCanvas.onCropChanged = { l,t,r,b in session.update { $0.crop.setCrop(left:l,top:t,right:r,bottom:b) } }
|
|
1378
|
+
cropCanvas.onViewportChanged = { zoom,x,y in session.update { $0.crop.zoom = zoom; $0.crop.panX = x; $0.crop.panY = y } }
|
|
1379
|
+
cropCanvas.onMediaBoundsChanged = { [weak self] bounds in
|
|
1380
|
+
guard let self,let playerView = self.cropPlayerView else { return }
|
|
1381
|
+
let fitted = self.videoMediaBounds(session:session,in:preview,cropped:false)
|
|
1382
|
+
let scale = bounds.width/max(fitted.width,0.001)
|
|
1383
|
+
playerView.transform = CGAffineTransform(translationX:bounds.midX-fitted.midX,y:bounds.midY-fitted.midY).scaledBy(x:scale,y:scale)
|
|
1384
|
+
}
|
|
1117
1385
|
let videoLayers = LayerOverlayView()
|
|
1118
1386
|
videoLayerOverlay = videoLayers
|
|
1119
1387
|
preview.addSubview(videoLayers)
|
|
@@ -1124,6 +1392,13 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1124
1392
|
videoLayers.topAnchor.constraint(equalTo: preview.topAnchor),
|
|
1125
1393
|
videoLayers.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
1126
1394
|
])
|
|
1395
|
+
videoLayers.onLayerDelete = { [weak self] id in
|
|
1396
|
+
session.layerStack.commit { $0.filter { $0.id != id } }
|
|
1397
|
+
self?.selectVideoLayer(nil, session: session); self?.onVideoLayerStackChanged()
|
|
1398
|
+
}
|
|
1399
|
+
videoLayers.onLayerDoubleTapped = { [weak self] id in
|
|
1400
|
+
if let layer = session.layerStack.layers.first(where: { $0.id == id && $0.type == .text }) { self?.showVideoTextInputDialog(session: session, editingLayer: layer) }
|
|
1401
|
+
}
|
|
1127
1402
|
videoLayers.onLayerTapped = { [weak self] id in self?.selectVideoLayer(id, session: session) }
|
|
1128
1403
|
videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
1129
1404
|
guard let self else { return }
|
|
@@ -1141,26 +1416,51 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1141
1416
|
self.onVideoLayerStackChanged()
|
|
1142
1417
|
}
|
|
1143
1418
|
|
|
1144
|
-
let playPause =
|
|
1419
|
+
let playPause = UIButton(type: .system)
|
|
1420
|
+
playPause.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
1421
|
+
playPause.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
1422
|
+
playPause.tintColor = .white
|
|
1423
|
+
playPause.backgroundColor = UIColor.black.withAlphaComponent(0.55)
|
|
1424
|
+
playPause.layer.cornerRadius = 20
|
|
1425
|
+
playPause.setImage(UIImage(systemName: "play.fill"), for: .normal)
|
|
1426
|
+
playPause.accessibilityLabel = "Play video"
|
|
1427
|
+
playPause.applyPressScale()
|
|
1428
|
+
playPause.addAction(UIAction { [weak self] _ in
|
|
1145
1429
|
guard let self, let session = self.videoSession else { return }
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1430
|
+
if session.player.timeControlStatus == .playing {
|
|
1431
|
+
session.player.pause()
|
|
1432
|
+
} else {
|
|
1433
|
+
let current = CMTimeGetSeconds(session.player.currentTime())
|
|
1434
|
+
let duration = Double(session.durationMs) / 1000
|
|
1435
|
+
if current.isFinite, duration > 0, current >= duration - 0.05 {
|
|
1436
|
+
session.player.seek(to: .zero) { finished in
|
|
1437
|
+
if finished { session.player.rate = session.state.speed }
|
|
1438
|
+
}
|
|
1439
|
+
} else {
|
|
1440
|
+
session.player.rate = session.state.speed
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
self.setVideoControlsVisible(true)
|
|
1444
|
+
}, for: .touchUpInside)
|
|
1149
1445
|
playPauseButton = playPause
|
|
1150
1446
|
let time = UILabel()
|
|
1151
1447
|
time.text = "0:00 / 0:00"
|
|
1152
|
-
time.textColor =
|
|
1153
|
-
time.font = .
|
|
1448
|
+
time.textColor = .white
|
|
1449
|
+
time.font = .monospacedDigitSystemFont(ofSize: 11, weight: .medium)
|
|
1154
1450
|
timeLabel = time
|
|
1155
1451
|
let transport = UIStackView(arrangedSubviews: [playPause, time])
|
|
1156
1452
|
transport.axis = .horizontal
|
|
1157
1453
|
transport.alignment = .center
|
|
1158
1454
|
transport.spacing = 8
|
|
1159
|
-
transport.backgroundColor = toolbarColor
|
|
1160
|
-
transport.isLayoutMarginsRelativeArrangement = true
|
|
1161
|
-
transport.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
|
1162
1455
|
|
|
1163
1456
|
let position = UISlider()
|
|
1457
|
+
position.accessibilityLabel = "Playback position"
|
|
1458
|
+
position.minimumTrackTintColor = primaryColor
|
|
1459
|
+
position.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.35)
|
|
1460
|
+
if let thumb = UIImage(systemName: "circle.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 11)) {
|
|
1461
|
+
position.setThumbImage(thumb.withTintColor(.white, renderingMode: .alwaysOriginal), for: .normal)
|
|
1462
|
+
}
|
|
1463
|
+
position.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
|
1164
1464
|
position.minimumValue = 0
|
|
1165
1465
|
position.maximumValue = 1000
|
|
1166
1466
|
position.addTarget(self, action: #selector(positionSliderChanged(_:)), for: .valueChanged)
|
|
@@ -1168,6 +1468,22 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1168
1468
|
position.addTarget(self, action: #selector(positionSliderTouchUp(_:)), for: [.touchUpInside, .touchUpOutside])
|
|
1169
1469
|
positionSlider = position
|
|
1170
1470
|
|
|
1471
|
+
let controlsOverlay = UIStackView(arrangedSubviews: [transport, position])
|
|
1472
|
+
controlsOverlay.axis = .vertical
|
|
1473
|
+
controlsOverlay.spacing = 2
|
|
1474
|
+
controlsOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
|
1475
|
+
controlsOverlay.isLayoutMarginsRelativeArrangement = true
|
|
1476
|
+
controlsOverlay.layoutMargins = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
|
1477
|
+
videoControlsOverlay = controlsOverlay
|
|
1478
|
+
preview.addSubview(controlsOverlay)
|
|
1479
|
+
controlsOverlay.translatesAutoresizingMaskIntoConstraints = false
|
|
1480
|
+
NSLayoutConstraint.activate([
|
|
1481
|
+
controlsOverlay.leadingAnchor.constraint(equalTo: preview.leadingAnchor),
|
|
1482
|
+
controlsOverlay.trailingAnchor.constraint(equalTo: preview.trailingAnchor),
|
|
1483
|
+
controlsOverlay.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
1484
|
+
])
|
|
1485
|
+
preview.addGestureRecognizer(ClosureTapGestureRecognizer(action: { [weak self] in self?.toggleVideoControls() }))
|
|
1486
|
+
|
|
1171
1487
|
let trim = TrimRangeView()
|
|
1172
1488
|
// Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
|
|
1173
1489
|
// own `originalDurationMs`, not the composed multi-clip timeline.
|
|
@@ -1213,24 +1529,75 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1213
1529
|
}
|
|
1214
1530
|
}
|
|
1215
1531
|
|
|
1532
|
+
let cropPanel = CropPanel(accent:primaryColor,surface:toolbarColor,foreground:textColor,
|
|
1533
|
+
originalRatio: { [weak self] in let size = self?.videoSourceSize(session:session) ?? CGSize(width:1,height:1); return size.width/size.height },
|
|
1534
|
+
onReset: { [weak self] in session.update { $0.crop = PhotoTransformState() }; self?.refreshVideoCrop(session:session) },
|
|
1535
|
+
onDone: { [weak self] in self?.setVideoCropMode(false,session:session) },
|
|
1536
|
+
onCancel: { [weak self] in if let saved = self?.videoCropEntryState { session.update { $0.crop = saved } }; self?.setVideoCropMode(false,session:session) },
|
|
1537
|
+
onStraighten: { [weak self] value in session.update { $0.crop.setStraighten(value) }; self?.refreshVideoCrop(session:session) },
|
|
1538
|
+
onRotate: { [weak self] in
|
|
1539
|
+
session.update { $0.crop.rotateRight(); $0.crop.zoom = 1; $0.crop.panX = 0; $0.crop.panY = 0; $0.crop.aspectRatio = nil; $0.crop.aspectPreset = "Free" }
|
|
1540
|
+
self?.refreshVideoCrop(session:session)
|
|
1541
|
+
},
|
|
1542
|
+
onRatio: { [weak self] label,ratio in
|
|
1543
|
+
session.update { $0.crop.aspectRatio = ratio; $0.crop.aspectPreset = label }
|
|
1544
|
+
cropCanvas.setAspectRatio(ratio); self?.videoCropPanel?.sync(session.state.crop)
|
|
1545
|
+
})
|
|
1546
|
+
cropPanel.isHidden = true; videoCropPanel = cropPanel
|
|
1547
|
+
cropPanel.heightAnchor.constraint(equalToConstant:232).isActive = true
|
|
1216
1548
|
let aspectBar = makeVideoAspectSubBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
|
|
1217
1549
|
aspectBar.isHidden = true
|
|
1218
1550
|
videoAspectSubBar = aspectBar
|
|
1219
1551
|
|
|
1220
|
-
let layerSlider =
|
|
1552
|
+
let layerSlider = EditorToolSlider()
|
|
1221
1553
|
layerSlider.isHidden = true
|
|
1222
1554
|
layerSlider.addTarget(self, action: #selector(videoLayerPropertyChanged(_:)), for: .valueChanged)
|
|
1555
|
+
configureLayerSlider(layerSlider, video: true)
|
|
1223
1556
|
videoLayerPropertySlider = layerSlider
|
|
1224
1557
|
|
|
1558
|
+
let palette = makeTextPalette(video: true) { [weak self] color in
|
|
1559
|
+
guard let self, let id = self.selectedVideoLayerID else { return }
|
|
1560
|
+
session.layerStack.commit { list in list.map { item in
|
|
1561
|
+
var updated = item; if item.id == id { updated.textColor = color }; return updated
|
|
1562
|
+
} }
|
|
1563
|
+
self.onVideoLayerStackChanged(); self.setVideoLayerToolBarVisible(true, session: session)
|
|
1564
|
+
}
|
|
1565
|
+
palette.isHidden = true; videoColorRow = palette
|
|
1566
|
+
palette.heightAnchor.constraint(equalToConstant: 48).isActive = true
|
|
1225
1567
|
let layerBar = makeVideoLayerToolBar(session: session, textColor: textColor, primaryColor: primaryColor, toolbarColor: toolbarColor)
|
|
1226
1568
|
layerBar.isHidden = true
|
|
1227
1569
|
videoLayerToolBar = layerBar
|
|
1228
1570
|
|
|
1229
|
-
let toolbar = makeVideoToolBar(session: session, playerController: playerController
|
|
1571
|
+
let toolbar = makeVideoToolBar(session: session, playerController: playerController)
|
|
1230
1572
|
videoToolBar = toolbar
|
|
1231
1573
|
|
|
1232
|
-
let
|
|
1574
|
+
let previewWrapper = UIView()
|
|
1575
|
+
previewWrapper.addSubview(preview)
|
|
1576
|
+
preview.translatesAutoresizingMaskIntoConstraints = false
|
|
1577
|
+
NSLayoutConstraint.activate([
|
|
1578
|
+
preview.leadingAnchor.constraint(equalTo: previewWrapper.leadingAnchor),
|
|
1579
|
+
preview.trailingAnchor.constraint(equalTo: previewWrapper.trailingAnchor),
|
|
1580
|
+
preview.topAnchor.constraint(equalTo: previewWrapper.topAnchor, constant: DesignTokens.spaceXs),
|
|
1581
|
+
preview.bottomAnchor.constraint(equalTo: previewWrapper.bottomAnchor, constant: -DesignTokens.spaceXs),
|
|
1582
|
+
])
|
|
1583
|
+
|
|
1584
|
+
// Transparent holder so the safe-area inset lands on empty space rather than stretching the pill.
|
|
1585
|
+
let toolbarWrapper = UIView()
|
|
1586
|
+
toolbarWrapper.addSubview(toolbar)
|
|
1587
|
+
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
|
1588
|
+
NSLayoutConstraint.activate([
|
|
1589
|
+
toolbar.leadingAnchor.constraint(equalTo: toolbarWrapper.leadingAnchor, constant: DesignTokens.spaceMd),
|
|
1590
|
+
toolbar.trailingAnchor.constraint(equalTo: toolbarWrapper.trailingAnchor, constant: -DesignTokens.spaceMd),
|
|
1591
|
+
toolbar.topAnchor.constraint(equalTo: toolbarWrapper.topAnchor),
|
|
1592
|
+
toolbar.bottomAnchor.constraint(equalTo: toolbarWrapper.bottomAnchor, constant: -DesignTokens.spaceSm),
|
|
1593
|
+
])
|
|
1594
|
+
|
|
1595
|
+
// The editor is intentionally single-video: omit the multi-clip strip and
|
|
1596
|
+
// its Split/Copy/Delete/Reorder controls from the visible layout. Playback
|
|
1597
|
+
// controls (transport/position) live inside `preview` itself, not in this stack.
|
|
1598
|
+
let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1233
1599
|
root.axis = .vertical
|
|
1600
|
+
root.spacing = DesignTokens.spaceSm
|
|
1234
1601
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
1235
1602
|
view.addSubview(root)
|
|
1236
1603
|
NSLayoutConstraint.activate([
|
|
@@ -1239,14 +1606,16 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1239
1606
|
root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
1240
1607
|
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
1241
1608
|
header.heightAnchor.constraint(equalToConstant: 60),
|
|
1242
|
-
transport.heightAnchor.constraint(equalToConstant: 40),
|
|
1243
1609
|
trim.heightAnchor.constraint(equalToConstant: 48),
|
|
1244
1610
|
stripScroll.heightAnchor.constraint(equalToConstant: 40),
|
|
1245
1611
|
aspectBar.heightAnchor.constraint(equalToConstant: 56),
|
|
1246
|
-
layerBar.heightAnchor.constraint(equalToConstant:
|
|
1247
|
-
toolbar.heightAnchor.constraint(equalToConstant:
|
|
1612
|
+
layerBar.heightAnchor.constraint(equalToConstant: 76),
|
|
1613
|
+
toolbar.heightAnchor.constraint(equalToConstant: 52),
|
|
1248
1614
|
])
|
|
1249
1615
|
|
|
1616
|
+
root.arrangedSubviews.forEach { child in
|
|
1617
|
+
child.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.priority = UILayoutPriority(999) }
|
|
1618
|
+
}
|
|
1250
1619
|
let progress = UIView()
|
|
1251
1620
|
progress.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
|
1252
1621
|
progress.isHidden = true
|
|
@@ -1276,6 +1645,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1276
1645
|
}
|
|
1277
1646
|
|
|
1278
1647
|
private func startPositionPolling() {
|
|
1648
|
+
var wasPlaying = false
|
|
1279
1649
|
positionPollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
1280
1650
|
guard let self, let session = self.videoSession else { return }
|
|
1281
1651
|
if !self.seekingPosition, session.durationMs > 0 {
|
|
@@ -1283,11 +1653,44 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1283
1653
|
self.positionSlider?.value = Float(min(max(Double(currentMs) / Double(session.durationMs), 0), 1)) * 1000
|
|
1284
1654
|
self.updateTimeLabel()
|
|
1285
1655
|
}
|
|
1286
|
-
|
|
1656
|
+
let playing = session.player.timeControlStatus == .playing
|
|
1657
|
+
self.playPauseButton?.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
|
|
1658
|
+
self.playPauseButton?.accessibilityLabel = playing ? "Pause video" : "Play video"
|
|
1659
|
+
if playing != wasPlaying {
|
|
1660
|
+
wasPlaying = playing
|
|
1661
|
+
if playing { self.scheduleAutoHideVideoControls() } else { self.setVideoControlsVisible(true) }
|
|
1662
|
+
}
|
|
1287
1663
|
self.refreshVideoOverlayPreview(session: session)
|
|
1288
1664
|
}
|
|
1289
1665
|
}
|
|
1290
1666
|
|
|
1667
|
+
/// Shows/hides the in-preview playback controls with a subtle fade (spec: auto-hide while playing).
|
|
1668
|
+
private func setVideoControlsVisible(_ visible: Bool) {
|
|
1669
|
+
guard let overlay = videoControlsOverlay else { return }
|
|
1670
|
+
hideVideoControlsTimer?.invalidate()
|
|
1671
|
+
videoControlsVisible = visible
|
|
1672
|
+
UIView.animate(withDuration: 0.2) {
|
|
1673
|
+
overlay.alpha = visible ? 1 : 0
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
private func scheduleAutoHideVideoControls() {
|
|
1678
|
+
hideVideoControlsTimer?.invalidate()
|
|
1679
|
+
guard let session = videoSession, session.player.timeControlStatus == .playing else { return }
|
|
1680
|
+
hideVideoControlsTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
|
|
1681
|
+
self?.setVideoControlsVisible(false)
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
private func toggleVideoControls() {
|
|
1686
|
+
if videoControlsVisible {
|
|
1687
|
+
setVideoControlsVisible(false)
|
|
1688
|
+
} else {
|
|
1689
|
+
setVideoControlsVisible(true)
|
|
1690
|
+
scheduleAutoHideVideoControls()
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1291
1694
|
private func updateTimeLabel() {
|
|
1292
1695
|
guard let session = videoSession else { return }
|
|
1293
1696
|
let currentMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
|
|
@@ -1341,23 +1744,51 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1341
1744
|
}
|
|
1342
1745
|
|
|
1343
1746
|
/// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
|
|
1344
|
-
private func
|
|
1345
|
-
guard let clip = session.clips.first,
|
|
1346
|
-
let
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1747
|
+
private func videoSourceSize(session:VideoEditSession) -> CGSize {
|
|
1748
|
+
guard let clip = session.clips.first,let track = session.asset(for:clip).tracks(withMediaType:.video).first else { return CGSize(width:1,height:1) }
|
|
1749
|
+
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1750
|
+
return CGSize(width:max(1,abs(size.width)),height:max(1,abs(size.height)))
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
private func videoMediaBounds(session:VideoEditSession,in view:UIView,cropped:Bool) -> CGRect {
|
|
1754
|
+
let size = VideoCropGeometry.plan(size:videoSourceSize(session:session),state:session.state.crop,includeCrop:cropped).size
|
|
1755
|
+
return AVMakeRect(aspectRatio:size,insideRect:view.bounds)
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
private func computeVideoLetterboxBounds(session:VideoEditSession,in view:UIView) -> CGRect {
|
|
1759
|
+
videoMediaBounds(session:session,in:view,cropped:!videoCropMode)
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
private func refreshVideoCrop(session:VideoEditSession) {
|
|
1763
|
+
if let item = session.player.currentItem {
|
|
1764
|
+
item.videoComposition = VideoCropGeometry.previewComposition(asset:item.asset,state:session.state.crop,includeCrop:!videoCropMode)
|
|
1765
|
+
// Refresh the paused frame after changing a composition.
|
|
1766
|
+
session.player.seek(to:session.player.currentTime(),toleranceBefore:.zero,toleranceAfter:.zero)
|
|
1767
|
+
}
|
|
1768
|
+
cropPlayerView?.transform = .identity
|
|
1769
|
+
videoCropPanel?.sync(session.state.crop)
|
|
1770
|
+
view.layoutIfNeeded()
|
|
1771
|
+
if videoCropMode,let preview = videoPreviewContainer { videoCropOverlay?.restore(bounds:videoMediaBounds(session:session,in:preview,cropped:false),state:session.state.crop) }
|
|
1772
|
+
videoNaturalSize = VideoCropGeometry.plan(size:videoSourceSize(session:session),state:session.state.crop,includeCrop:true).size
|
|
1773
|
+
videoOverlayRenderKey = nil; refreshVideoOverlayPreview(session:session)
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private func setVideoCropMode(_ enabled:Bool,session:VideoEditSession) {
|
|
1777
|
+
if enabled && !videoCropMode { videoCropEntryState = session.state.crop; session.player.pause(); selectVideoLayer(nil,session:session) }
|
|
1778
|
+
videoCropMode = enabled
|
|
1779
|
+
videoCropPanel?.isHidden = !enabled; videoCropOverlay?.isHidden = !enabled
|
|
1780
|
+
videoLayerOverlay?.isHidden = enabled; videoOverlayImageView?.isHidden = enabled; videoToolBar?.isHidden = enabled
|
|
1781
|
+
if !enabled { videoCropEntryState = nil }
|
|
1782
|
+
refreshVideoCrop(session:session)
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
override func viewDidLayoutSubviews() {
|
|
1786
|
+
super.viewDidLayoutSubviews()
|
|
1787
|
+
if videoCropMode,let session = videoSession,let preview = videoPreviewContainer {
|
|
1788
|
+
videoCropOverlay?.restore(bounds:videoMediaBounds(session:session,in:preview,cropped:false),state:session.state.crop)
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1361
1792
|
private func onVideoLayerStackChanged() {
|
|
1362
1793
|
guard let session = videoSession else { return }
|
|
1363
1794
|
if let id = selectedVideoLayerID, !session.layerStack.layers.contains(where: { $0.id == id }) {
|
|
@@ -1373,16 +1804,25 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1373
1804
|
}
|
|
1374
1805
|
|
|
1375
1806
|
private func selectVideoLayer(_ id: String?, session: VideoEditSession) {
|
|
1807
|
+
if selectedVideoLayerID != id { activeVideoLayerPropertyKey = "" }
|
|
1376
1808
|
selectedVideoLayerID = id
|
|
1377
1809
|
videoLayerOverlay?.selectedLayerID = id
|
|
1378
1810
|
setVideoLayerToolBarVisible(id != nil, session: session)
|
|
1379
1811
|
}
|
|
1380
1812
|
|
|
1381
1813
|
private func setVideoLayerToolBarVisible(_ visible: Bool, session: VideoEditSession) {
|
|
1814
|
+
if visible { setVideoControlsVisible(true) }
|
|
1382
1815
|
videoLayerToolBar?.isHidden = !visible
|
|
1383
|
-
videoLayerPropertySlider?.isHidden = !visible
|
|
1384
1816
|
videoToolBar?.isHidden = visible
|
|
1817
|
+
let isText = currentSelectedVideoLayer(session: session)?.type == .text
|
|
1818
|
+
videoLayerPropertyButtons["scale"]?.isHidden = isText || currentSelectedVideoLayer(session: session)?.type == .sticker
|
|
1819
|
+
["edit", "color", "fontSize"].forEach { videoLayerPropertyButtons[$0]?.isHidden = !isText }
|
|
1820
|
+
if !isText && ["color", "fontSize"].contains(activeVideoLayerPropertyKey) { activeVideoLayerPropertyKey = "" }
|
|
1821
|
+
videoLayerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeVideoLayerPropertyKey)
|
|
1822
|
+
videoColorRow?.isHidden = !visible || activeVideoLayerPropertyKey != "color"
|
|
1385
1823
|
if visible { syncVideoLayerPropertySlider(session: session) }
|
|
1824
|
+
refreshContextSelection(videoLayerPropertyButtons, active: activeVideoLayerPropertyKey)
|
|
1825
|
+
updateSwatches(videoTextSwatches, selected: currentSelectedVideoLayer(session: session)?.textColor)
|
|
1386
1826
|
}
|
|
1387
1827
|
|
|
1388
1828
|
private func currentSelectedVideoLayer(session: VideoEditSession) -> PhotoLayer? {
|
|
@@ -1393,14 +1833,16 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1393
1833
|
guard let slider = videoLayerPropertySlider, let layer = currentSelectedVideoLayer(session: session) else { return }
|
|
1394
1834
|
let (min, max, value): (CGFloat, CGFloat, CGFloat)
|
|
1395
1835
|
switch activeVideoLayerPropertyKey {
|
|
1396
|
-
case "rotation": (min, max, value) = (-180, 180, layer.rotationDegrees)
|
|
1836
|
+
case "rotation": (min, max, value) = (-180, 180, (layer.rotationDegrees.truncatingRemainder(dividingBy: 360) + 540).truncatingRemainder(dividingBy: 360) - 180)
|
|
1397
1837
|
case "opacity": (min, max, value) = (0, 100, layer.opacity * 100)
|
|
1838
|
+
case "fontSize": (min, max, value) = (12, 160, layer.fontSize)
|
|
1398
1839
|
case "start": (min, max, value) = (0, CGFloat(session.durationMs), CGFloat(layer.startMs))
|
|
1399
1840
|
case "end":
|
|
1400
1841
|
let effectiveEnd = (layer.endMs > 0 && layer.endMs <= session.durationMs) ? layer.endMs : session.durationMs
|
|
1401
1842
|
(min, max, value) = (0, CGFloat(session.durationMs), CGFloat(effectiveEnd))
|
|
1402
1843
|
default: (min, max, value) = (20, 800, layer.scale * 100)
|
|
1403
1844
|
}
|
|
1845
|
+
(slider as? EditorToolSlider)?.propertyKey = slider === layerPropertySlider ? activeLayerPropertyKey : activeVideoLayerPropertyKey
|
|
1404
1846
|
slider.minimumValue = Float(min)
|
|
1405
1847
|
slider.maximumValue = Float(max)
|
|
1406
1848
|
slider.value = Float(value)
|
|
@@ -1408,6 +1850,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1408
1850
|
|
|
1409
1851
|
@objc private func videoLayerPropertyChanged(_ slider: UISlider) {
|
|
1410
1852
|
guard let session = videoSession, let id = selectedVideoLayerID else { return }
|
|
1853
|
+
slider.setNeedsDisplay()
|
|
1411
1854
|
let value = CGFloat(slider.value)
|
|
1412
1855
|
let key = activeVideoLayerPropertyKey
|
|
1413
1856
|
session.layerStack.updateLive { list in
|
|
@@ -1417,6 +1860,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1417
1860
|
switch key {
|
|
1418
1861
|
case "rotation": updated.rotationDegrees = value
|
|
1419
1862
|
case "opacity": updated.opacity = value / 100
|
|
1863
|
+
case "fontSize": updated.fontSize = value
|
|
1420
1864
|
case "start": updated.startMs = min(Int64(value), (layer.endMs > 0 ? layer.endMs : session.durationMs))
|
|
1421
1865
|
case "end": updated.endMs = max(Int64(value), layer.startMs)
|
|
1422
1866
|
default: updated.scale = value / 100
|
|
@@ -1428,61 +1872,37 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1428
1872
|
}
|
|
1429
1873
|
|
|
1430
1874
|
private func makeVideoLayerToolBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
self
|
|
1438
|
-
|
|
1439
|
-
|
|
1875
|
+
makeContextToolbar(video: true, toolbarColor: toolbarColor) { [weak self] key in
|
|
1876
|
+
guard let self, let layer = self.currentSelectedVideoLayer(session: session) else { return }
|
|
1877
|
+
switch key {
|
|
1878
|
+
case "edit": self.showVideoTextInputDialog(session: session, editingLayer: layer)
|
|
1879
|
+
case "done": self.selectVideoLayer(nil, session: session)
|
|
1880
|
+
case "delete":
|
|
1881
|
+
session.layerStack.commit { $0.filter { $0.id != layer.id } }; self.selectVideoLayer(nil, session: session); self.onVideoLayerStackChanged()
|
|
1882
|
+
case "duplicate":
|
|
1883
|
+
let copy = layer.duplicated()
|
|
1884
|
+
session.layerStack.commit { $0 + [copy] }; self.selectVideoLayer(copy.id, session: session); self.onVideoLayerStackChanged()
|
|
1885
|
+
default:
|
|
1886
|
+
self.activeVideoLayerPropertyKey = self.activeVideoLayerPropertyKey == key ? "" : key
|
|
1887
|
+
self.setVideoLayerToolBarVisible(true, session: session)
|
|
1440
1888
|
}
|
|
1441
|
-
videoLayerPropertyButtons[key] = chip
|
|
1442
|
-
chipStack.addArrangedSubview(chip)
|
|
1443
1889
|
}
|
|
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
1890
|
}
|
|
1466
1891
|
|
|
1467
|
-
private func showVideoTextInputDialog(session: VideoEditSession) {
|
|
1468
|
-
let
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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)
|
|
1892
|
+
private func showVideoTextInputDialog(session: VideoEditSession, editingLayer: PhotoLayer? = nil) {
|
|
1893
|
+
let sheet = TextEditorSheet(text: editingLayer?.text ?? "", editing: editingLayer != nil, accent: color("primaryColor") ?? DesignTokens.primaryContainer) { [weak self] text in
|
|
1894
|
+
var layer = editingLayer ?? PhotoLayer(type: .text); layer.text = text
|
|
1895
|
+
session.layerStack.commit { list in editingLayer == nil ? list + [layer] : list.map { $0.id == layer.id ? layer : $0 } }
|
|
1896
|
+
self?.onVideoLayerStackChanged(); self?.selectVideoLayer(layer.id, session: session)
|
|
1897
|
+
}
|
|
1898
|
+
present(sheet, animated: true)
|
|
1480
1899
|
}
|
|
1481
1900
|
|
|
1482
|
-
@objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true }
|
|
1901
|
+
@objc private func positionSliderTouchDown(_ slider: UISlider) { seekingPosition = true; setVideoControlsVisible(true) }
|
|
1483
1902
|
|
|
1484
1903
|
@objc private func positionSliderTouchUp(_ slider: UISlider) {
|
|
1485
1904
|
seekingPosition = false
|
|
1905
|
+
scheduleAutoHideVideoControls()
|
|
1486
1906
|
guard let session = videoSession, session.durationMs > 0 else { return }
|
|
1487
1907
|
let targetMs = Double(slider.value / 1000) * Double(session.durationMs)
|
|
1488
1908
|
session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
|
|
@@ -1494,30 +1914,50 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1494
1914
|
session.player.seek(to: CMTime(value: Int64(targetMs), timescale: 1000))
|
|
1495
1915
|
}
|
|
1496
1916
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1917
|
+
/// Compact video dock: two equal-width icon+label actions in one rounded pill. Deliberately not
|
|
1918
|
+
/// `toolButton` — that rail stacks a symbol above a label (72pt of chrome), far more than two
|
|
1919
|
+
/// actions warrant. `textformat` renders as "Aa", which reads as typography where a lone "T" does not.
|
|
1920
|
+
private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController) -> UIView {
|
|
1921
|
+
let tools: [(String, String, String)] = [
|
|
1922
|
+
("text", "Text", "textformat"), ("stickers", "Stickers", "face.smiling"),
|
|
1501
1923
|
]
|
|
1502
|
-
let
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1924
|
+
let dock = UIStackView()
|
|
1925
|
+
dock.axis = .horizontal
|
|
1926
|
+
dock.distribution = .fillEqually
|
|
1927
|
+
dock.backgroundColor = DesignTokens.surfaceContainer
|
|
1928
|
+
dock.layer.cornerRadius = 18
|
|
1929
|
+
dock.clipsToBounds = true
|
|
1930
|
+
tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
|
|
1931
|
+
dock.addArrangedSubview(makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
|
|
1506
1932
|
self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
|
|
1507
|
-
}
|
|
1508
|
-
videoToolButtons[key] = toolButton
|
|
1509
|
-
stack.addArrangedSubview(toolButton)
|
|
1933
|
+
})
|
|
1510
1934
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1935
|
+
return dock
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/// One dock action: symbol + label on a single row, purple pill while pressed.
|
|
1939
|
+
private func makeVideoDockAction(label: String, symbol: String, action: @escaping () -> Void) -> UIButton {
|
|
1940
|
+
let control = UIButton(type: .system)
|
|
1941
|
+
var config = UIButton.Configuration.plain()
|
|
1942
|
+
config.title = label
|
|
1943
|
+
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium))
|
|
1944
|
+
config.imagePlacement = .leading
|
|
1945
|
+
config.imagePadding = DesignTokens.spaceSm
|
|
1946
|
+
config.baseForegroundColor = DesignTokens.textPrimary
|
|
1947
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: DesignTokens.spaceSm, bottom: 0, trailing: DesignTokens.spaceSm)
|
|
1948
|
+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
1949
|
+
var value = incoming; value.font = .systemFont(ofSize: 13, weight: .semibold); return value
|
|
1950
|
+
}
|
|
1951
|
+
control.configuration = config
|
|
1952
|
+
control.accessibilityLabel = label
|
|
1953
|
+
let accent = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
1954
|
+
control.configurationUpdateHandler = { button in
|
|
1955
|
+
button.configuration?.background.backgroundColor = button.isHighlighted ? accent : .clear
|
|
1956
|
+
button.configuration?.background.cornerRadius = 18
|
|
1957
|
+
}
|
|
1958
|
+
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
1959
|
+
control.applyPressScale()
|
|
1960
|
+
return control
|
|
1521
1961
|
}
|
|
1522
1962
|
|
|
1523
1963
|
private func refreshVideoToolSelection(session: VideoEditSession) {
|
|
@@ -1535,6 +1975,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1535
1975
|
|
|
1536
1976
|
private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
|
|
1537
1977
|
switch key {
|
|
1978
|
+
case "crop": setVideoCropMode(true,session:session)
|
|
1538
1979
|
case "cover":
|
|
1539
1980
|
let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
|
|
1540
1981
|
session.update { $0.coverFrameMs = atMs }
|
|
@@ -1560,37 +2001,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1560
2001
|
}
|
|
1561
2002
|
|
|
1562
2003
|
private func showVideoStickerPicker(session: VideoEditSession) {
|
|
1563
|
-
|
|
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)
|
|
2004
|
+
presentOnlineStickerSheet(purpose: .videoSticker)
|
|
1594
2005
|
}
|
|
1595
2006
|
|
|
1596
2007
|
private func formatSpeedLabel(_ speed: Float) -> String {
|
|
@@ -1842,7 +2253,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1842
2253
|
let title = UILabel()
|
|
1843
2254
|
title.text = mediaType == "photo" ? "Photo Editor" : "Video Editor"
|
|
1844
2255
|
title.textColor = textColor
|
|
1845
|
-
title.font = .systemFont(ofSize:
|
|
2256
|
+
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
2257
|
+
title.adjustsFontSizeToFitWidth = true
|
|
2258
|
+
title.minimumScaleFactor = 0.75
|
|
1846
2259
|
title.textAlignment = .center
|
|
1847
2260
|
|
|
1848
2261
|
header.addArrangedSubview(close)
|
|
@@ -1857,15 +2270,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1857
2270
|
self?.photoSession?.layerStack.redo()
|
|
1858
2271
|
self?.onLayerStackChanged()
|
|
1859
2272
|
}
|
|
2273
|
+
undo.isEnabled = false; undo.alpha = 0.4
|
|
2274
|
+
redo.isEnabled = false; redo.alpha = 0.4
|
|
1860
2275
|
undoButton = undo
|
|
1861
2276
|
redoButton = redo
|
|
1862
2277
|
header.addArrangedSubview(undo)
|
|
1863
2278
|
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
2279
|
} else if mediaType == "video" {
|
|
1870
2280
|
let undo = iconButton(systemName: "arrow.uturn.backward", tint: textColor) { [weak self] in
|
|
1871
2281
|
self?.videoSession?.layerStack.undo()
|
|
@@ -1875,6 +2285,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1875
2285
|
self?.videoSession?.layerStack.redo()
|
|
1876
2286
|
self?.onVideoLayerStackChanged()
|
|
1877
2287
|
}
|
|
2288
|
+
undo.isEnabled = false; undo.alpha = 0.4
|
|
2289
|
+
redo.isEnabled = false; redo.alpha = 0.4
|
|
1878
2290
|
undoButton = undo
|
|
1879
2291
|
redoButton = redo
|
|
1880
2292
|
header.addArrangedSubview(undo)
|
|
@@ -1884,7 +2296,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1884
2296
|
let onPrimaryColor = color("onPrimaryColor") ?? DesignTokens.onPrimaryContainer
|
|
1885
2297
|
let export = EditorComponents.primaryButton(
|
|
1886
2298
|
text: "Export",
|
|
1887
|
-
systemImage:
|
|
2299
|
+
systemImage: nil,
|
|
1888
2300
|
fillColor: primaryColor,
|
|
1889
2301
|
textColor: onPrimaryColor
|
|
1890
2302
|
) { [weak self] in
|
|
@@ -1956,6 +2368,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1956
2368
|
}
|
|
1957
2369
|
}
|
|
1958
2370
|
|
|
2371
|
+
@objc private func compareHeld(_ recognizer: UILongPressGestureRecognizer) {
|
|
2372
|
+
switch recognizer.state {
|
|
2373
|
+
case .began: compareTouchDown()
|
|
2374
|
+
case .ended, .cancelled, .failed: compareTouchUp()
|
|
2375
|
+
default: break
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
1959
2379
|
@objc private func compareTouchDown() {
|
|
1960
2380
|
guard !comparingOriginal, let session = photoSession else { return }
|
|
1961
2381
|
comparingOriginal = true
|
|
@@ -1998,7 +2418,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1998
2418
|
guard self.viewIfLoaded?.window != nil,
|
|
1999
2419
|
let session = self.photoSession,
|
|
2000
2420
|
let imageView = self.photoImageView else { return }
|
|
2001
|
-
autoreleasepool {
|
|
2421
|
+
autoreleasepool {
|
|
2422
|
+
imageView.image = session.renderPreview(cropping:self.cropMode)
|
|
2423
|
+
imageView.resetToFit()
|
|
2424
|
+
if self.cropMode { self.cropOverlay?.restore(bounds:imageView.currentImageBounds(),state:session.state) }
|
|
2425
|
+
}
|
|
2002
2426
|
}
|
|
2003
2427
|
}
|
|
2004
2428
|
|