react-native-photo-video-editor 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +27 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +81 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +7 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +26 -0
- package/android/src/main/res/drawable/ic_swap_sticker.xml +3 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +455 -151
- package/ios/Editor/UI/CropPanel.swift +10 -3
- package/ios/Editor/UI/EditorComponents.swift +53 -10
- package/ios/Editor/UI/EditorPanel.swift +228 -155
- package/ios/Editor/UI/EditorToolSlider.swift +4 -2
- package/ios/Files/SourceResolver.swift +131 -3
- package/ios/Photo/Export/PhotoExporter.swift +2 -2
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +57 -63
- package/ios/Photo/Rendering/PixelBuffer.swift +24 -18
- package/ios/Photo/UI/FilterThumbnailLoader.swift +3 -3
- package/ios/Photo/UI/LayerOverlayView.swift +18 -0
- package/ios/PhotoVideoEditor.mm +33 -2
- package/ios/PhotoVideoEditor.swift +22 -18
- package/ios/Stickers/OnlineStickerSheetViewController.swift +1 -1
- package/ios/Video/Render/VideoEditSession.swift +30 -11
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +2 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +24 -0
- package/src/types.ts +2 -1
|
@@ -15,6 +15,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
15
15
|
private let features: [String: Any]
|
|
16
16
|
private let theme: [String: Any]
|
|
17
17
|
private let exportOptions: [String: Any]
|
|
18
|
+
private let initialStickerIds: [String]
|
|
19
|
+
private let initialStickerLayerID = UUID().uuidString
|
|
20
|
+
private var initialStickerImages: [String: (URL, UIImage)] = [:]
|
|
21
|
+
private var initialStickerLoading = false
|
|
18
22
|
private let stickerAssets: [[String: Any]]
|
|
19
23
|
var completion: ((PhotoVideoEditorOutcome) -> Void)?
|
|
20
24
|
|
|
@@ -58,6 +62,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
58
62
|
private var redoButton: UIButton?
|
|
59
63
|
private var progressOverlay: UIView?
|
|
60
64
|
private var photoPreviewRenderPending = false
|
|
65
|
+
private var isRenderingPhotoPreview = false
|
|
66
|
+
private let photoRenderQueue = DispatchQueue(label: "pve.photo.render", qos: .userInteractive)
|
|
67
|
+
private var drawColorSwatches: [(UIColor, UIButton)] = []
|
|
61
68
|
private var comparingOriginal = false
|
|
62
69
|
private var selectedPhotoExportFormat: String?
|
|
63
70
|
|
|
@@ -114,7 +121,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
114
121
|
features: [String: Any],
|
|
115
122
|
theme: [String: Any] = [:],
|
|
116
123
|
exportOptions: [String: Any] = [:],
|
|
117
|
-
stickerAssets: [[String: Any]] = []
|
|
124
|
+
stickerAssets: [[String: Any]] = [],
|
|
125
|
+
initialStickerIds: [String] = []
|
|
118
126
|
) {
|
|
119
127
|
self.uri = uri
|
|
120
128
|
self.mediaType = mediaType
|
|
@@ -122,6 +130,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
122
130
|
self.theme = theme
|
|
123
131
|
self.exportOptions = exportOptions
|
|
124
132
|
self.stickerAssets = stickerAssets
|
|
133
|
+
self.initialStickerIds = initialStickerIds
|
|
125
134
|
super.init(nibName: nil, bundle: nil)
|
|
126
135
|
modalPresentationStyle = .fullScreen
|
|
127
136
|
}
|
|
@@ -151,6 +160,80 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
151
160
|
} else {
|
|
152
161
|
buildVideoEditor()
|
|
153
162
|
}
|
|
163
|
+
insertInitialSticker()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func currentInitialSticker() -> PhotoLayer? {
|
|
167
|
+
(photoSession?.layerStack.layers ?? videoSession?.layerStack.layers ?? []).first { $0.id == initialStickerLayerID }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func switchInitialSticker() {
|
|
171
|
+
guard !initialStickerLoading, initialStickerIds.count > 1 else { return }
|
|
172
|
+
let currentID = currentInitialSticker()?.stickerId
|
|
173
|
+
let currentIndex = currentID.flatMap { initialStickerIds.firstIndex(of: $0) } ?? -1
|
|
174
|
+
insertInitialSticker(index: (currentIndex + 1) % initialStickerIds.count)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private func insertInitialSticker(index: Int = 0) {
|
|
178
|
+
guard !initialStickerLoading, initialStickerIds.indices.contains(index) else { return }
|
|
179
|
+
let id = initialStickerIds[index]
|
|
180
|
+
guard let asset = consumerStickerAssets().first(where: { $0.id == id }) else { return }
|
|
181
|
+
initialStickerLoading = true
|
|
182
|
+
view.isUserInteractionEnabled = false
|
|
183
|
+
let apply: (URL?, UIImage?) -> Void = { [weak self] url, image in
|
|
184
|
+
guard let self else { return }
|
|
185
|
+
self.initialStickerLoading = false
|
|
186
|
+
self.view.isUserInteractionEnabled = true
|
|
187
|
+
guard let url, let image else {
|
|
188
|
+
if self.initialStickerImages.isEmpty {
|
|
189
|
+
self.completion?(.failure(code: "E_SOURCE_UNREADABLE", message: "The initial sticker could not be loaded."))
|
|
190
|
+
} else {
|
|
191
|
+
let alert = UIAlertController(title: "Couldn't load sticker", message: "Please try again.", preferredStyle: .alert)
|
|
192
|
+
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
|
193
|
+
self.present(alert, animated: true)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
self.initialStickerImages[id] = (url, image)
|
|
198
|
+
self.view.layoutIfNeeded()
|
|
199
|
+
var layer = self.currentInitialSticker() ?? self.newSticker()
|
|
200
|
+
layer.id = self.initialStickerLayerID
|
|
201
|
+
layer.stickerId = id
|
|
202
|
+
layer.stickerUri = url.absoluteString
|
|
203
|
+
layer.overlayAspectRatio = max(image.size.width, 1) / max(image.size.height, 1)
|
|
204
|
+
let replace: ([PhotoLayer]) -> [PhotoLayer] = { layers in
|
|
205
|
+
if layers.contains(where: { $0.id == self.initialStickerLayerID }) {
|
|
206
|
+
return layers.map { $0.id == self.initialStickerLayerID ? layer : $0 }
|
|
207
|
+
}
|
|
208
|
+
return layers + [layer]
|
|
209
|
+
}
|
|
210
|
+
if let session = self.photoSession {
|
|
211
|
+
session.layerStack.commit(replace)
|
|
212
|
+
self.onLayerStackChanged()
|
|
213
|
+
self.selectLayer(layer.id)
|
|
214
|
+
} else if let session = self.videoSession {
|
|
215
|
+
session.layerStack.commit(replace)
|
|
216
|
+
self.onVideoLayerStackChanged()
|
|
217
|
+
self.selectVideoLayer(layer.id, session: session)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if let cached = initialStickerImages[id] {
|
|
221
|
+
apply(cached.0, cached.1)
|
|
222
|
+
} else {
|
|
223
|
+
let loader = OnlineStickerSheetViewController()
|
|
224
|
+
loader.loadRuntimeSticker(.init(id: id, uri: asset.uri)) { [loader] url, image in
|
|
225
|
+
_ = loader // Keep the loader alive until its download finishes.
|
|
226
|
+
apply(url, image)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
232
|
+
super.viewDidAppear(animated)
|
|
233
|
+
if mediaType == "photo" {
|
|
234
|
+
photoImageView?.resetToFit()
|
|
235
|
+
schedulePhotoPreviewRender()
|
|
236
|
+
}
|
|
154
237
|
}
|
|
155
238
|
|
|
156
239
|
// MARK: - Photo editor (crop, rotate, straighten, export — Milestone 2)
|
|
@@ -176,7 +259,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
176
259
|
return
|
|
177
260
|
}
|
|
178
261
|
|
|
179
|
-
let imageView = ZoomableImageView()
|
|
262
|
+
let imageView = ZoomableImageView(frame: .zero)
|
|
180
263
|
imageView.image = session.renderPreview() ?? baseImage
|
|
181
264
|
photoImageView = imageView
|
|
182
265
|
preview.addSubview(imageView)
|
|
@@ -200,7 +283,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
200
283
|
overlay.bottomAnchor.constraint(equalTo: preview.bottomAnchor),
|
|
201
284
|
])
|
|
202
285
|
|
|
203
|
-
let layers = LayerOverlayView()
|
|
286
|
+
let layers = LayerOverlayView(frame: .zero)
|
|
287
|
+
layers.swappableLayerID = initialStickerLayerID
|
|
288
|
+
layers.stickerSwapEnabled = initialStickerIds.count > 1
|
|
289
|
+
layers.onStickerSwap = { [weak self] in self?.switchInitialSticker() }
|
|
204
290
|
layerOverlay = layers
|
|
205
291
|
preview.addSubview(layers)
|
|
206
292
|
layers.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -247,9 +333,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
247
333
|
layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
248
334
|
guard let self, let session = self.photoSession else { return }
|
|
249
335
|
if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
|
|
250
|
-
session.layerStack.updateLive { list in
|
|
251
|
-
|
|
252
|
-
|
|
336
|
+
session.layerStack.updateLive { list in
|
|
337
|
+
list.map { current in
|
|
338
|
+
guard current.id == id else { return current }
|
|
339
|
+
var layer = current
|
|
340
|
+
layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation
|
|
341
|
+
return layer
|
|
342
|
+
}
|
|
343
|
+
}
|
|
253
344
|
layers.layers = session.layerStack.layers
|
|
254
345
|
self.schedulePhotoPreviewRender()
|
|
255
346
|
}
|
|
@@ -330,7 +421,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
330
421
|
filtersBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight + 24),
|
|
331
422
|
stickersBar.heightAnchor.constraint(equalToConstant: 56),
|
|
332
423
|
drawBar.heightAnchor.constraint(equalToConstant: 56),
|
|
333
|
-
|
|
424
|
+
layerSlider.heightAnchor.constraint(equalToConstant: 64),
|
|
425
|
+
layerBar.heightAnchor.constraint(equalToConstant: 68),
|
|
334
426
|
colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
|
|
335
427
|
])
|
|
336
428
|
|
|
@@ -360,75 +452,122 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
360
452
|
imageView.resetToFit()
|
|
361
453
|
}
|
|
362
454
|
|
|
363
|
-
///
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
let tile
|
|
455
|
+
/// Interactive tool dock button with tactile feedback, balanced icon/label spacing,
|
|
456
|
+
/// and seamless scroll coordination without conflicting gesture recognizers.
|
|
457
|
+
private final class ToolButton: UIControl {
|
|
458
|
+
let tile = UIView()
|
|
367
459
|
let icon: UIImageView
|
|
368
|
-
let label
|
|
460
|
+
let label = UILabel()
|
|
461
|
+
private var onTapAction: (() -> Void)?
|
|
462
|
+
|
|
463
|
+
init(systemName: String, labelText: String, onTap: (() -> Void)? = nil) {
|
|
464
|
+
self.onTapAction = onTap
|
|
465
|
+
self.icon = UIImageView(image: UIImage(systemName: systemName))
|
|
466
|
+
super.init(frame: .zero)
|
|
467
|
+
setupViews(labelText: labelText)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
471
|
+
|
|
472
|
+
private func setupViews(labelText: String) {
|
|
473
|
+
isAccessibilityElement = true
|
|
474
|
+
accessibilityLabel = labelText
|
|
475
|
+
accessibilityTraits = .button
|
|
476
|
+
|
|
477
|
+
tile.isUserInteractionEnabled = false
|
|
478
|
+
tile.backgroundColor = .clear
|
|
479
|
+
tile.layer.cornerRadius = 15
|
|
480
|
+
tile.clipsToBounds = true
|
|
481
|
+
|
|
482
|
+
icon.isUserInteractionEnabled = false
|
|
483
|
+
icon.tintColor = DesignTokens.textSecondary
|
|
484
|
+
icon.contentMode = .scaleAspectFit
|
|
485
|
+
|
|
486
|
+
tile.addSubview(icon)
|
|
487
|
+
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
488
|
+
NSLayoutConstraint.activate([
|
|
489
|
+
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
490
|
+
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
|
491
|
+
icon.widthAnchor.constraint(equalToConstant: 18),
|
|
492
|
+
icon.heightAnchor.constraint(equalToConstant: 18),
|
|
493
|
+
])
|
|
494
|
+
|
|
495
|
+
label.isUserInteractionEnabled = false
|
|
496
|
+
label.text = labelText
|
|
497
|
+
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
498
|
+
label.textColor = DesignTokens.textSecondary
|
|
499
|
+
label.textAlignment = .center
|
|
500
|
+
label.numberOfLines = 1
|
|
501
|
+
label.adjustsFontSizeToFitWidth = true
|
|
502
|
+
label.minimumScaleFactor = 0.85
|
|
503
|
+
|
|
504
|
+
let contentStack = UIStackView(arrangedSubviews: [tile, label])
|
|
505
|
+
contentStack.axis = .vertical
|
|
506
|
+
contentStack.alignment = .center
|
|
507
|
+
contentStack.spacing = 3
|
|
508
|
+
contentStack.isUserInteractionEnabled = false
|
|
509
|
+
|
|
510
|
+
addSubview(contentStack)
|
|
511
|
+
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
512
|
+
NSLayoutConstraint.activate([
|
|
513
|
+
tile.widthAnchor.constraint(equalToConstant: 30),
|
|
514
|
+
tile.heightAnchor.constraint(equalToConstant: 30),
|
|
515
|
+
contentStack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
516
|
+
contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
517
|
+
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 2),
|
|
518
|
+
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -2),
|
|
519
|
+
])
|
|
520
|
+
|
|
521
|
+
if onTapAction != nil {
|
|
522
|
+
addTarget(self, action: #selector(handleTap), for: .touchUpInside)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
@objc private func handleTap() {
|
|
527
|
+
onTapAction?()
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
override var isHighlighted: Bool {
|
|
531
|
+
didSet {
|
|
532
|
+
UIView.animate(
|
|
533
|
+
withDuration: isHighlighted ? 0.08 : 0.14,
|
|
534
|
+
delay: 0,
|
|
535
|
+
options: [.allowUserInteraction, .beginFromCurrentState],
|
|
536
|
+
animations: { self.transform = self.isHighlighted ? CGAffineTransform(scaleX: 0.94, y: 0.94) : .identity }
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
func setSelectedState(_ selected: Bool, accent: UIColor) {
|
|
542
|
+
accessibilityTraits = selected ? [.button, .selected] : .button
|
|
543
|
+
tile.backgroundColor = selected ? accent.withAlphaComponent(0.18) : .clear
|
|
544
|
+
icon.tintColor = selected ? accent : DesignTokens.textSecondary
|
|
545
|
+
label.textColor = selected ? accent : DesignTokens.textSecondary
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// Icon-tile + label node in the main tool rail (Studio Violet "Tool Node").
|
|
550
|
+
private struct ToolNode {
|
|
551
|
+
let button: ToolButton
|
|
552
|
+
var root: UIView { button }
|
|
553
|
+
var tile: UIView { button.tile }
|
|
554
|
+
var icon: UIImageView { button.icon }
|
|
555
|
+
var label: UILabel { button.label }
|
|
369
556
|
}
|
|
370
557
|
|
|
371
558
|
private var photoToolNodes: [String: ToolNode] = [:]
|
|
372
559
|
private weak var photoToolScroll: UIScrollView?
|
|
373
|
-
/// Backs `createToolNode`'s tap handling: each node's root view registers its action here, keyed by
|
|
374
|
-
/// its own identity, and a single shared `UITapGestureRecognizer` target (`self`) looks it up.
|
|
375
|
-
private var toolNodeActions: [ObjectIdentifier: () -> Void] = [:]
|
|
376
|
-
|
|
377
|
-
@objc private func handleToolNodeTap(_ recognizer: UITapGestureRecognizer) {
|
|
378
|
-
guard let view = recognizer.view else { return }
|
|
379
|
-
toolNodeActions[ObjectIdentifier(view)]?()
|
|
380
|
-
}
|
|
381
560
|
|
|
382
|
-
private func createToolNode(systemName: String, labelText: String, onTap:
|
|
383
|
-
let
|
|
384
|
-
|
|
385
|
-
icon.contentMode = .scaleAspectFit
|
|
386
|
-
let tile = UIView()
|
|
387
|
-
tile.backgroundColor = .clear
|
|
388
|
-
tile.layer.cornerRadius = DesignTokens.touchTargetMin / 2
|
|
389
|
-
tile.addSubview(icon)
|
|
390
|
-
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
391
|
-
NSLayoutConstraint.activate([
|
|
392
|
-
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
393
|
-
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
|
394
|
-
icon.widthAnchor.constraint(equalToConstant: 22),
|
|
395
|
-
icon.heightAnchor.constraint(equalToConstant: 22),
|
|
396
|
-
])
|
|
397
|
-
let label = UILabel()
|
|
398
|
-
label.text = labelText
|
|
399
|
-
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
400
|
-
label.textColor = DesignTokens.textSecondary
|
|
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
|
|
406
|
-
let root = UIStackView(arrangedSubviews: [tile, label])
|
|
407
|
-
root.axis = .vertical
|
|
408
|
-
root.alignment = .center
|
|
409
|
-
root.spacing = 4
|
|
410
|
-
root.isUserInteractionEnabled = true
|
|
411
|
-
toolNodeActions[ObjectIdentifier(root)] = onTap
|
|
412
|
-
root.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleToolNodeTap(_:))))
|
|
413
|
-
root.applyPressScale()
|
|
414
|
-
root.isAccessibilityElement = true
|
|
415
|
-
root.accessibilityLabel = labelText
|
|
416
|
-
root.accessibilityTraits = .button
|
|
417
|
-
NSLayoutConstraint.activate([
|
|
418
|
-
tile.widthAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
|
|
419
|
-
tile.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin),
|
|
420
|
-
])
|
|
421
|
-
return ToolNode(root: root, tile: tile, icon: icon, label: label)
|
|
561
|
+
private func createToolNode(systemName: String, labelText: String, onTap: (() -> Void)? = nil) -> ToolNode {
|
|
562
|
+
let button = ToolButton(systemName: systemName, labelText: labelText, onTap: onTap)
|
|
563
|
+
return ToolNode(button: button)
|
|
422
564
|
}
|
|
423
565
|
|
|
424
566
|
/// Selection is a purple icon + label over a faint purple disc — not a filled tile, which at dock
|
|
425
567
|
/// size reads as a large button and fights the photo for attention.
|
|
426
568
|
private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
|
|
427
569
|
let accent = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
428
|
-
node.
|
|
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
|
|
570
|
+
node.button.setSelectedState(selected, accent: accent)
|
|
432
571
|
}
|
|
433
572
|
|
|
434
573
|
/// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
|
|
@@ -436,43 +575,53 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
436
575
|
private func makePhotoToolBar(textColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
437
576
|
let tools: [(String, String, String)] = [
|
|
438
577
|
("crop", "Crop", "crop"),
|
|
578
|
+
("rotate", "Rotate", "rotate.right"),
|
|
439
579
|
("adjust", "Adjust", "slider.horizontal.3"),
|
|
440
580
|
("filters", "Filters", "camera.filters"),
|
|
441
581
|
("text", "Text", "textformat"),
|
|
442
582
|
("stickers", "Stickers", "face.smiling"),
|
|
443
583
|
("draw", "Draw", "pencil.tip"),
|
|
584
|
+
("overlay", "Overlay", "plus.rectangle.on.rectangle"),
|
|
444
585
|
("resize", "Resize", "aspectratio"),
|
|
445
586
|
]
|
|
446
|
-
let scroll = UIScrollView()
|
|
587
|
+
let scroll = UIScrollView()
|
|
588
|
+
scroll.backgroundColor = toolbarColor
|
|
447
589
|
scroll.showsHorizontalScrollIndicator = false
|
|
590
|
+
scroll.alwaysBounceHorizontal = true
|
|
591
|
+
scroll.delaysContentTouches = true
|
|
592
|
+
scroll.canCancelContentTouches = true
|
|
448
593
|
photoToolScroll = scroll
|
|
449
|
-
let stack = UIStackView()
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let itemWidth =
|
|
453
|
-
tools.filter {
|
|
594
|
+
let stack = UIStackView()
|
|
595
|
+
stack.axis = .horizontal
|
|
596
|
+
stack.spacing = 2
|
|
597
|
+
let itemWidth: CGFloat = 58
|
|
598
|
+
tools.filter { key, _, _ in
|
|
599
|
+
if key == "overlay" {
|
|
600
|
+
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
601
|
+
}
|
|
602
|
+
return features[key] as? Bool != false
|
|
603
|
+
}.forEach { key, label, symbol in
|
|
454
604
|
let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
|
|
455
|
-
node.
|
|
605
|
+
node.button.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
456
606
|
photoToolNodes[key] = node
|
|
457
|
-
stack.addArrangedSubview(node.
|
|
458
|
-
}
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
compare.
|
|
463
|
-
compare.
|
|
464
|
-
|
|
465
|
-
hold.minimumPressDuration = 0
|
|
466
|
-
compare.root.addGestureRecognizer(hold)
|
|
607
|
+
stack.addArrangedSubview(node.button)
|
|
608
|
+
}
|
|
609
|
+
// "Original" compare button uses native touch-down / touch-up events without gesture recognizers.
|
|
610
|
+
let compare = createToolNode(systemName: "rectangle.split.2x1", labelText: "Original")
|
|
611
|
+
compare.button.accessibilityLabel = "Hold to compare with original"
|
|
612
|
+
compare.button.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
613
|
+
compare.button.addTarget(self, action: #selector(compareTouchDown), for: .touchDown)
|
|
614
|
+
compare.button.addTarget(self, action: #selector(compareTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
|
467
615
|
photoToolNodes["compare"] = compare
|
|
468
|
-
stack.addArrangedSubview(compare.
|
|
616
|
+
stack.addArrangedSubview(compare.button)
|
|
617
|
+
|
|
469
618
|
scroll.addSubview(stack)
|
|
470
619
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
471
620
|
NSLayoutConstraint.activate([
|
|
472
621
|
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
473
|
-
|
|
622
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
|
|
474
623
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
475
|
-
|
|
624
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
476
625
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
477
626
|
])
|
|
478
627
|
return scroll
|
|
@@ -504,6 +653,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
504
653
|
switch key {
|
|
505
654
|
case "crop":
|
|
506
655
|
setCropMode(true)
|
|
656
|
+
case "rotate":
|
|
657
|
+
session.update { $0.rotateRight(); $0.zoom = 1; $0.panX = 0; $0.panY = 0 }
|
|
658
|
+
photoCropPanel?.sync(session.state)
|
|
659
|
+
schedulePhotoPreviewRender()
|
|
507
660
|
case "adjust":
|
|
508
661
|
setAdjustMode(true, session: session)
|
|
509
662
|
case "filters":
|
|
@@ -516,6 +669,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
516
669
|
presentOnlineStickerSheet(purpose: .photoSticker)
|
|
517
670
|
case "draw":
|
|
518
671
|
setDrawMode(true, session: session)
|
|
672
|
+
case "overlay", "overlays":
|
|
673
|
+
presentImagePicker(purpose: .photoOverlay)
|
|
519
674
|
case "resize":
|
|
520
675
|
setCropMode(true)
|
|
521
676
|
default:
|
|
@@ -557,6 +712,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
557
712
|
private func makeStickersSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
558
713
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
559
714
|
let scroll = UIScrollView()
|
|
715
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
716
|
+
scroll.alwaysBounceHorizontal = true
|
|
717
|
+
scroll.delaysContentTouches = true
|
|
718
|
+
scroll.canCancelContentTouches = true
|
|
560
719
|
let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
|
|
561
720
|
PhotoLayerRenderer.builtinStickerIDs().forEach { id in
|
|
562
721
|
chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
|
|
@@ -586,9 +745,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
586
745
|
chipStack.translatesAutoresizingMaskIntoConstraints = false
|
|
587
746
|
NSLayoutConstraint.activate([
|
|
588
747
|
chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
589
|
-
|
|
748
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: chipStack.trailingAnchor, constant: 8),
|
|
590
749
|
chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
591
|
-
|
|
750
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: chipStack.bottomAnchor),
|
|
592
751
|
chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
593
752
|
])
|
|
594
753
|
bar.addArrangedSubview(scroll)
|
|
@@ -607,8 +766,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
607
766
|
layerOverlay?.isHidden = enabled
|
|
608
767
|
photoImageView?.panZoomEnabled = !enabled
|
|
609
768
|
if enabled {
|
|
610
|
-
drawOverlay?.strokeColor = .
|
|
769
|
+
drawOverlay?.strokeColor = .systemRed
|
|
611
770
|
drawOverlay?.strokeWidthPx = 4
|
|
771
|
+
updateDrawSwatches(selected: .systemRed)
|
|
612
772
|
if let bounds = photoImageView?.currentImageBounds() { drawOverlay?.setImageBounds(bounds) }
|
|
613
773
|
} else {
|
|
614
774
|
commitDrawStrokes(session: session)
|
|
@@ -636,21 +796,53 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
636
796
|
}
|
|
637
797
|
|
|
638
798
|
private func makeDrawSubBar(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
639
|
-
|
|
799
|
+
drawColorSwatches.removeAll()
|
|
800
|
+
let colors: [(String, UIColor)] = [
|
|
801
|
+
("Red", .systemRed),
|
|
802
|
+
("Yellow", .systemYellow),
|
|
803
|
+
("Green", .systemGreen),
|
|
804
|
+
("Blue", .systemBlue),
|
|
805
|
+
("Purple", DesignTokens.primary),
|
|
806
|
+
("Pink", .systemPink),
|
|
807
|
+
("White", .white),
|
|
808
|
+
("Black", .black),
|
|
809
|
+
]
|
|
640
810
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
641
811
|
let scroll = UIScrollView()
|
|
642
|
-
|
|
643
|
-
|
|
812
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
813
|
+
scroll.alwaysBounceHorizontal = true
|
|
814
|
+
scroll.delaysContentTouches = true
|
|
815
|
+
scroll.canCancelContentTouches = true
|
|
816
|
+
let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 6
|
|
817
|
+
controls.alignment = .center
|
|
818
|
+
|
|
819
|
+
colors.forEach { name, color in
|
|
820
|
+
let swatch = createDrawColorSwatch(color: color) { [weak self] in
|
|
821
|
+
self?.drawOverlay?.strokeColor = color
|
|
822
|
+
self?.updateDrawSwatches(selected: color)
|
|
823
|
+
}
|
|
824
|
+
swatch.accessibilityLabel = name
|
|
825
|
+
drawColorSwatches.append((color, swatch))
|
|
826
|
+
controls.addArrangedSubview(swatch)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
let divider = UIView()
|
|
830
|
+
divider.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
831
|
+
divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
|
|
832
|
+
divider.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
833
|
+
controls.addArrangedSubview(divider)
|
|
834
|
+
|
|
644
835
|
controls.addArrangedSubview(button("Thin", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 2 })
|
|
645
836
|
controls.addArrangedSubview(button("Thick", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 10 })
|
|
646
837
|
controls.addArrangedSubview(button("Undo", color: textColor) { [weak self] in self?.drawOverlay?.undoLastStroke() })
|
|
647
838
|
controls.addArrangedSubview(button("Clear", color: textColor) { [weak self] in self?.drawOverlay?.clearStrokes() })
|
|
839
|
+
|
|
648
840
|
scroll.addSubview(controls); controls.translatesAutoresizingMaskIntoConstraints = false
|
|
649
841
|
NSLayoutConstraint.activate([
|
|
650
842
|
controls.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
651
|
-
|
|
843
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 8),
|
|
652
844
|
controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
653
|
-
|
|
845
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: controls.bottomAnchor),
|
|
654
846
|
controls.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
655
847
|
])
|
|
656
848
|
bar.addArrangedSubview(scroll)
|
|
@@ -661,9 +853,43 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
661
853
|
bar.backgroundColor = toolbarColor
|
|
662
854
|
bar.isLayoutMarginsRelativeArrangement = true
|
|
663
855
|
bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
|
856
|
+
updateDrawSwatches(selected: .systemRed)
|
|
664
857
|
return bar
|
|
665
858
|
}
|
|
666
859
|
|
|
860
|
+
private func createDrawColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIButton {
|
|
861
|
+
let outer = UIButton(type: .custom)
|
|
862
|
+
outer.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
863
|
+
outer.layer.cornerRadius = 16
|
|
864
|
+
outer.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
865
|
+
outer.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
866
|
+
let inner = UIView()
|
|
867
|
+
inner.backgroundColor = color
|
|
868
|
+
inner.layer.cornerRadius = 10
|
|
869
|
+
inner.isUserInteractionEnabled = false
|
|
870
|
+
inner.translatesAutoresizingMaskIntoConstraints = false
|
|
871
|
+
outer.addSubview(inner)
|
|
872
|
+
NSLayoutConstraint.activate([
|
|
873
|
+
inner.centerXAnchor.constraint(equalTo: outer.centerXAnchor),
|
|
874
|
+
inner.centerYAnchor.constraint(equalTo: outer.centerYAnchor),
|
|
875
|
+
inner.widthAnchor.constraint(equalToConstant: 20),
|
|
876
|
+
inner.heightAnchor.constraint(equalToConstant: 20),
|
|
877
|
+
])
|
|
878
|
+
outer.addAction(UIAction { _ in onTap() }, for: .touchUpInside)
|
|
879
|
+
outer.applyPressScale()
|
|
880
|
+
return outer
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private func updateDrawSwatches(selected: UIColor) {
|
|
884
|
+
let primary = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
885
|
+
drawColorSwatches.forEach { color, swatch in
|
|
886
|
+
let isSel = color == selected
|
|
887
|
+
swatch.layer.borderWidth = isSel ? 2 : 0
|
|
888
|
+
swatch.layer.borderColor = primary.cgColor
|
|
889
|
+
swatch.accessibilityTraits = isSel ? [.button, .selected] : .button
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
667
893
|
private func consumerStickerAssets() -> [(id: String, uri: String)] {
|
|
668
894
|
stickerAssets.compactMap { asset in
|
|
669
895
|
guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
|
|
@@ -776,7 +1002,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
776
1002
|
|
|
777
1003
|
/// Keeps the active tool-rail node fully visible, never partially clipped at the scroll edge.
|
|
778
1004
|
private func scrollToolNodeIntoView(_ node: ToolNode) {
|
|
779
|
-
guard let scroll = photoToolScroll else { return }
|
|
1005
|
+
guard let scroll = photoToolScroll, !cropMode, !scroll.isHidden else { return }
|
|
780
1006
|
DispatchQueue.main.async {
|
|
781
1007
|
let frame = node.root.convert(node.root.bounds, to: scroll)
|
|
782
1008
|
scroll.scrollRectToVisible(frame.insetBy(dx: -DesignTokens.spaceSm, dy: 0), animated: true)
|
|
@@ -784,7 +1010,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
784
1010
|
}
|
|
785
1011
|
|
|
786
1012
|
private func selectLayer(_ id: String?) {
|
|
787
|
-
if selectedLayerID != id {
|
|
1013
|
+
if selectedLayerID != id {
|
|
1014
|
+
let isSticker = photoSession?.layerStack.layers.first(where: { $0.id == id })?.type == .sticker
|
|
1015
|
+
activeLayerPropertyKey = isSticker ? "opacity" : ""
|
|
1016
|
+
}
|
|
788
1017
|
selectedLayerID = id
|
|
789
1018
|
layerOverlay?.selectedLayerID = id
|
|
790
1019
|
if let session = photoSession {
|
|
@@ -804,9 +1033,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
804
1033
|
layerToolBar?.isHidden = !visible
|
|
805
1034
|
mainToolBar?.isHidden = visible
|
|
806
1035
|
let isText = currentSelectedLayer()?.type == .text
|
|
807
|
-
let
|
|
1036
|
+
let isSticker = currentSelectedLayer()?.type == .sticker
|
|
1037
|
+
let simple = isText || isSticker
|
|
808
1038
|
["scale", "lock", "visibility", "front", "back"].forEach { layerPropertyButtons[$0]?.isHidden = simple }
|
|
809
1039
|
["edit", "color", "fontSize"].forEach { layerPropertyButtons[$0]?.isHidden = !isText }
|
|
1040
|
+
if isSticker && activeLayerPropertyKey.isEmpty { activeLayerPropertyKey = "opacity" }
|
|
810
1041
|
if !isText && ["color", "fontSize"].contains(activeLayerPropertyKey) { activeLayerPropertyKey = "" }
|
|
811
1042
|
layerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeLayerPropertyKey)
|
|
812
1043
|
layerColorSwatchRow?.isHidden = !visible || activeLayerPropertyKey != "color"
|
|
@@ -906,18 +1137,18 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
906
1137
|
let control = UIButton(type: .system)
|
|
907
1138
|
var config = UIButton.Configuration.plain()
|
|
908
1139
|
config.title = label
|
|
909
|
-
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize:
|
|
1140
|
+
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))
|
|
910
1141
|
config.imagePlacement = .top
|
|
911
|
-
config.imagePadding =
|
|
1142
|
+
config.imagePadding = 4
|
|
912
1143
|
config.baseForegroundColor = DesignTokens.outline
|
|
913
1144
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
914
|
-
var value = incoming; value.font = .systemFont(ofSize:
|
|
1145
|
+
var value = incoming; value.font = .systemFont(ofSize: 10, weight: .medium); return value
|
|
915
1146
|
}
|
|
916
|
-
config.contentInsets = NSDirectionalEdgeInsets(top:
|
|
1147
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 2, bottom: 6, trailing: 2)
|
|
917
1148
|
control.configuration = config
|
|
918
1149
|
control.accessibilityLabel = label
|
|
919
|
-
let width = control.widthAnchor.constraint(equalToConstant:
|
|
920
|
-
control.heightAnchor.constraint(equalToConstant:
|
|
1150
|
+
let width = control.widthAnchor.constraint(equalToConstant: 54); width.priority = .defaultHigh; width.isActive = true
|
|
1151
|
+
control.heightAnchor.constraint(equalToConstant: 68).isActive = true
|
|
921
1152
|
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
922
1153
|
control.applyPressScale()
|
|
923
1154
|
return control
|
|
@@ -937,8 +1168,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
937
1168
|
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
1169
|
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
1170
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
940
|
-
let scroll = UIScrollView()
|
|
941
|
-
|
|
1171
|
+
let scroll = UIScrollView()
|
|
1172
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
1173
|
+
scroll.alwaysBounceHorizontal = true
|
|
1174
|
+
scroll.delaysContentTouches = true
|
|
1175
|
+
scroll.canCancelContentTouches = true
|
|
1176
|
+
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 2
|
|
942
1177
|
actions.forEach { key, label, symbol in
|
|
943
1178
|
let control = toolButton(label, symbol: symbol) { action(key) }
|
|
944
1179
|
if video { videoLayerPropertyButtons[key] = control } else { layerPropertyButtons[key] = control }
|
|
@@ -946,12 +1181,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
946
1181
|
}
|
|
947
1182
|
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
948
1183
|
NSLayoutConstraint.activate([
|
|
949
|
-
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant:
|
|
950
|
-
|
|
1184
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 6),
|
|
1185
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 6),
|
|
951
1186
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
952
|
-
|
|
1187
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
953
1188
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
954
|
-
scroll.heightAnchor.constraint(equalToConstant:
|
|
1189
|
+
scroll.heightAnchor.constraint(equalToConstant: 68),
|
|
955
1190
|
])
|
|
956
1191
|
bar.addArrangedSubview(scroll)
|
|
957
1192
|
let done = toolButton("Done", symbol: "checkmark") { action("done") }
|
|
@@ -1002,7 +1237,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1002
1237
|
}
|
|
1003
1238
|
|
|
1004
1239
|
private func makeTextPalette(video: Bool, onColor: @escaping (UIColor) -> Void) -> UIView {
|
|
1005
|
-
let scroll = UIScrollView()
|
|
1240
|
+
let scroll = UIScrollView()
|
|
1241
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
1242
|
+
scroll.alwaysBounceHorizontal = true
|
|
1243
|
+
scroll.delaysContentTouches = true
|
|
1244
|
+
scroll.canCancelContentTouches = true
|
|
1006
1245
|
scroll.backgroundColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
|
|
1007
1246
|
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 4
|
|
1008
1247
|
let colors: [(String, UIColor)] = [("White", .white), ("Black", .black), ("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow), ("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", DesignTokens.primary), ("Pink", .systemPink)]
|
|
@@ -1015,9 +1254,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1015
1254
|
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
1016
1255
|
NSLayoutConstraint.activate([
|
|
1017
1256
|
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
1018
|
-
|
|
1257
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
|
|
1019
1258
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
1020
|
-
|
|
1259
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
1021
1260
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
1022
1261
|
])
|
|
1023
1262
|
return scroll
|
|
@@ -1183,7 +1422,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1183
1422
|
)
|
|
1184
1423
|
|
|
1185
1424
|
let strip = UIStackView()
|
|
1186
|
-
strip.spacing =
|
|
1425
|
+
strip.spacing = 2
|
|
1187
1426
|
photoAdjustmentDefinitions.forEach { key, label, symbol in
|
|
1188
1427
|
let item = editorStripItem(systemName: symbol, labelText: label) { [weak self] in
|
|
1189
1428
|
self?.activeAdjustmentKey = key
|
|
@@ -1290,7 +1529,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1290
1529
|
}
|
|
1291
1530
|
|
|
1292
1531
|
@objc private func straightenChanged(_ slider: UISlider) {
|
|
1293
|
-
guard let session = photoSession,
|
|
1532
|
+
guard let session = photoSession, photoImageView != nil else { return }
|
|
1294
1533
|
session.update { $0.setStraighten(CGFloat(slider.value)) }
|
|
1295
1534
|
schedulePhotoPreviewRender()
|
|
1296
1535
|
}
|
|
@@ -1382,7 +1621,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1382
1621
|
let scale = bounds.width/max(fitted.width,0.001)
|
|
1383
1622
|
playerView.transform = CGAffineTransform(translationX:bounds.midX-fitted.midX,y:bounds.midY-fitted.midY).scaledBy(x:scale,y:scale)
|
|
1384
1623
|
}
|
|
1385
|
-
let videoLayers = LayerOverlayView()
|
|
1624
|
+
let videoLayers = LayerOverlayView(frame: .zero)
|
|
1625
|
+
videoLayers.swappableLayerID = initialStickerLayerID
|
|
1626
|
+
videoLayers.stickerSwapEnabled = initialStickerIds.count > 1
|
|
1627
|
+
videoLayers.onStickerSwap = { [weak self] in self?.switchInitialSticker() }
|
|
1386
1628
|
videoLayerOverlay = videoLayers
|
|
1387
1629
|
preview.addSubview(videoLayers)
|
|
1388
1630
|
videoLayers.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -1403,9 +1645,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1403
1645
|
videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
1404
1646
|
guard let self else { return }
|
|
1405
1647
|
if self.videoDragStartSnapshot == nil { self.videoDragStartSnapshot = session.layerStack.layers }
|
|
1406
|
-
session.layerStack.updateLive { list in
|
|
1407
|
-
|
|
1408
|
-
|
|
1648
|
+
session.layerStack.updateLive { list in
|
|
1649
|
+
list.map { current in
|
|
1650
|
+
guard current.id == id else { return current }
|
|
1651
|
+
var layer = current
|
|
1652
|
+
layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation
|
|
1653
|
+
return layer
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1409
1656
|
videoLayers.layers = session.layerStack.layers
|
|
1410
1657
|
self.refreshVideoOverlayPreview(session: session)
|
|
1411
1658
|
}
|
|
@@ -1592,10 +1839,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1592
1839
|
toolbar.bottomAnchor.constraint(equalTo: toolbarWrapper.bottomAnchor, constant: -DesignTokens.spaceSm),
|
|
1593
1840
|
])
|
|
1594
1841
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1842
|
+
let trimEnabled = features["trim"] as? Bool != false
|
|
1843
|
+
trim.isHidden = !trimEnabled
|
|
1844
|
+
let root = UIStackView(arrangedSubviews: [header, previewWrapper, trim, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1599
1845
|
root.axis = .vertical
|
|
1600
1846
|
root.spacing = DesignTokens.spaceSm
|
|
1601
1847
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -1607,9 +1853,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1607
1853
|
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
1608
1854
|
header.heightAnchor.constraint(equalToConstant: 60),
|
|
1609
1855
|
trim.heightAnchor.constraint(equalToConstant: 48),
|
|
1610
|
-
stripScroll.heightAnchor.constraint(equalToConstant: 40),
|
|
1611
1856
|
aspectBar.heightAnchor.constraint(equalToConstant: 56),
|
|
1612
|
-
layerBar.heightAnchor.constraint(equalToConstant:
|
|
1857
|
+
layerBar.heightAnchor.constraint(equalToConstant: 68),
|
|
1613
1858
|
toolbar.heightAnchor.constraint(equalToConstant: 52),
|
|
1614
1859
|
])
|
|
1615
1860
|
|
|
@@ -1744,10 +1989,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1744
1989
|
}
|
|
1745
1990
|
|
|
1746
1991
|
/// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
|
|
1747
|
-
private func videoSourceSize(session:VideoEditSession) -> CGSize {
|
|
1748
|
-
|
|
1992
|
+
private func videoSourceSize(session: VideoEditSession) -> CGSize {
|
|
1993
|
+
if session.naturalSize != .zero { return session.naturalSize }
|
|
1994
|
+
guard let clip = session.clips.first, let track = session.asset(for: clip).tracks(withMediaType: .video).first else { return CGSize(width: 1, height: 1) }
|
|
1749
1995
|
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1750
|
-
return CGSize(width:max(1,abs(size.width)),height:max(1,abs(size.height)))
|
|
1996
|
+
return CGSize(width: max(1, abs(size.width)), height: max(1, abs(size.height)))
|
|
1751
1997
|
}
|
|
1752
1998
|
|
|
1753
1999
|
private func videoMediaBounds(session:VideoEditSession,in view:UIView,cropped:Bool) -> CGRect {
|
|
@@ -1919,20 +2165,48 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1919
2165
|
/// actions warrant. `textformat` renders as "Aa", which reads as typography where a lone "T" does not.
|
|
1920
2166
|
private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController) -> UIView {
|
|
1921
2167
|
let tools: [(String, String, String)] = [
|
|
1922
|
-
("
|
|
2168
|
+
("trim", "Trim", "arrow.left.and.right"),
|
|
2169
|
+
("crop", "Crop", "crop"),
|
|
2170
|
+
("rotate", "Rotate", "rotate.right"),
|
|
2171
|
+
("speed", "Speed", "gauge.with.dots.needle.50percent"),
|
|
2172
|
+
("text", "Text", "textformat"),
|
|
2173
|
+
("stickers", "Stickers", "face.smiling"),
|
|
2174
|
+
("overlay", "Overlay", "plus.rectangle.on.rectangle"),
|
|
2175
|
+
("cover", "Cover", "photo"),
|
|
1923
2176
|
]
|
|
1924
|
-
let
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2177
|
+
let scroll = UIScrollView()
|
|
2178
|
+
scroll.backgroundColor = DesignTokens.surfaceContainer
|
|
2179
|
+
scroll.layer.cornerRadius = 18
|
|
2180
|
+
scroll.clipsToBounds = true
|
|
2181
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
2182
|
+
|
|
2183
|
+
let stack = UIStackView()
|
|
2184
|
+
stack.axis = .horizontal
|
|
2185
|
+
stack.spacing = DesignTokens.spaceXs
|
|
2186
|
+
|
|
2187
|
+
let itemWidth = Swift.min(Swift.max(UIScreen.main.bounds.width / 4.5, 76), 96)
|
|
2188
|
+
tools.filter { key, _, _ in
|
|
2189
|
+
if key == "overlay" {
|
|
2190
|
+
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
2191
|
+
}
|
|
2192
|
+
return features[key] as? Bool != false
|
|
2193
|
+
}.forEach { key, label, symbol in
|
|
2194
|
+
let control = makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
|
|
1932
2195
|
self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
|
|
1933
|
-
}
|
|
2196
|
+
}
|
|
2197
|
+
control.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
2198
|
+
stack.addArrangedSubview(control)
|
|
1934
2199
|
}
|
|
1935
|
-
|
|
2200
|
+
scroll.addSubview(stack)
|
|
2201
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
2202
|
+
NSLayoutConstraint.activate([
|
|
2203
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
2204
|
+
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
|
|
2205
|
+
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
2206
|
+
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
2207
|
+
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
2208
|
+
])
|
|
2209
|
+
return scroll
|
|
1936
2210
|
}
|
|
1937
2211
|
|
|
1938
2212
|
/// One dock action: symbol + label on a single row, purple pill while pressed.
|
|
@@ -1975,7 +2249,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1975
2249
|
|
|
1976
2250
|
private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
|
|
1977
2251
|
switch key {
|
|
1978
|
-
case "
|
|
2252
|
+
case "trim":
|
|
2253
|
+
guard let trim = trimRangeView else { return }
|
|
2254
|
+
trim.isHidden.toggle()
|
|
2255
|
+
case "crop":
|
|
2256
|
+
setVideoCropMode(true, session: session)
|
|
1979
2257
|
case "cover":
|
|
1980
2258
|
let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
|
|
1981
2259
|
session.update { $0.coverFrameMs = atMs }
|
|
@@ -1983,17 +2261,17 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1983
2261
|
case "rotate":
|
|
1984
2262
|
session.update { $0.rotateRight() }
|
|
1985
2263
|
applyVideoPreviewTransform(playerView, state: session.state)
|
|
1986
|
-
case "
|
|
2264
|
+
case "aspect", "resize":
|
|
1987
2265
|
setVideoAspectMode(true, session: session)
|
|
1988
2266
|
case "speed":
|
|
1989
2267
|
session.update { $0.cycleSpeed() }
|
|
1990
2268
|
if session.player.timeControlStatus == .playing { session.player.rate = session.state.speed }
|
|
1991
|
-
|
|
2269
|
+
showToast(formatSpeedLabel(session.state.speed))
|
|
1992
2270
|
case "text":
|
|
1993
2271
|
showVideoTextInputDialog(session: session)
|
|
1994
2272
|
case "stickers":
|
|
1995
2273
|
showVideoStickerPicker(session: session)
|
|
1996
|
-
case "overlay":
|
|
2274
|
+
case "overlay", "overlays":
|
|
1997
2275
|
presentImagePicker(purpose: .videoOverlay)
|
|
1998
2276
|
default:
|
|
1999
2277
|
showToast("\(key) is coming in a later milestone.")
|
|
@@ -2408,20 +2686,46 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2408
2686
|
present(sheet, animated: true)
|
|
2409
2687
|
}
|
|
2410
2688
|
|
|
2411
|
-
/// Coalesces slider and drag callbacks
|
|
2689
|
+
/// Coalesces slider and drag callbacks and performs rendering on a dedicated background queue
|
|
2690
|
+
/// so the main thread remains at 120 FPS without slider hitching or dragging lag.
|
|
2412
2691
|
private func schedulePhotoPreviewRender() {
|
|
2413
|
-
|
|
2414
|
-
|
|
2692
|
+
photoRenderQueue.async { [weak self] in
|
|
2693
|
+
guard let self else { return }
|
|
2694
|
+
if self.isRenderingPhotoPreview {
|
|
2695
|
+
self.photoPreviewRenderPending = true
|
|
2696
|
+
return
|
|
2697
|
+
}
|
|
2698
|
+
self.isRenderingPhotoPreview = true
|
|
2699
|
+
self.performPhotoPreviewRender()
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
private func performPhotoPreviewRender() {
|
|
2704
|
+
guard self.isViewLoaded,
|
|
2705
|
+
let session = self.photoSession else {
|
|
2706
|
+
self.isRenderingPhotoPreview = false
|
|
2707
|
+
return
|
|
2708
|
+
}
|
|
2709
|
+
let isCropping = self.cropMode
|
|
2710
|
+
let rendered = autoreleasepool {
|
|
2711
|
+
session.renderPreview(cropping: isCropping)
|
|
2712
|
+
}
|
|
2415
2713
|
DispatchQueue.main.async { [weak self] in
|
|
2416
2714
|
guard let self else { return }
|
|
2417
|
-
self.
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2715
|
+
if let rendered, let imageView = self.photoImageView {
|
|
2716
|
+
imageView.image = rendered
|
|
2717
|
+
if self.cropMode {
|
|
2718
|
+
self.cropOverlay?.restore(bounds: imageView.currentImageBounds(), state: session.state)
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
self.photoRenderQueue.async { [weak self] in
|
|
2722
|
+
guard let self else { return }
|
|
2723
|
+
if self.photoPreviewRenderPending {
|
|
2724
|
+
self.photoPreviewRenderPending = false
|
|
2725
|
+
self.performPhotoPreviewRender()
|
|
2726
|
+
} else {
|
|
2727
|
+
self.isRenderingPhotoPreview = false
|
|
2728
|
+
}
|
|
2425
2729
|
}
|
|
2426
2730
|
}
|
|
2427
2731
|
}
|