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