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