react-native-photo-video-editor 0.2.1 → 0.2.2
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 +13 -0
- package/README.md +17 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +45 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +7 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +397 -149
- 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/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 +5 -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 +20 -0
- package/src/types.ts +2 -1
|
@@ -15,6 +15,7 @@ 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 initialStickerId: String?
|
|
18
19
|
private let stickerAssets: [[String: Any]]
|
|
19
20
|
var completion: ((PhotoVideoEditorOutcome) -> Void)?
|
|
20
21
|
|
|
@@ -58,6 +59,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
58
59
|
private var redoButton: UIButton?
|
|
59
60
|
private var progressOverlay: UIView?
|
|
60
61
|
private var photoPreviewRenderPending = false
|
|
62
|
+
private var isRenderingPhotoPreview = false
|
|
63
|
+
private let photoRenderQueue = DispatchQueue(label: "pve.photo.render", qos: .userInteractive)
|
|
64
|
+
private var drawColorSwatches: [(UIColor, UIButton)] = []
|
|
61
65
|
private var comparingOriginal = false
|
|
62
66
|
private var selectedPhotoExportFormat: String?
|
|
63
67
|
|
|
@@ -114,7 +118,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
114
118
|
features: [String: Any],
|
|
115
119
|
theme: [String: Any] = [:],
|
|
116
120
|
exportOptions: [String: Any] = [:],
|
|
117
|
-
stickerAssets: [[String: Any]] = []
|
|
121
|
+
stickerAssets: [[String: Any]] = [],
|
|
122
|
+
initialStickerId: String? = nil
|
|
118
123
|
) {
|
|
119
124
|
self.uri = uri
|
|
120
125
|
self.mediaType = mediaType
|
|
@@ -122,6 +127,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
122
127
|
self.theme = theme
|
|
123
128
|
self.exportOptions = exportOptions
|
|
124
129
|
self.stickerAssets = stickerAssets
|
|
130
|
+
self.initialStickerId = initialStickerId
|
|
125
131
|
super.init(nibName: nil, bundle: nil)
|
|
126
132
|
modalPresentationStyle = .fullScreen
|
|
127
133
|
}
|
|
@@ -151,6 +157,33 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
151
157
|
} else {
|
|
152
158
|
buildVideoEditor()
|
|
153
159
|
}
|
|
160
|
+
insertInitialSticker()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func insertInitialSticker() {
|
|
164
|
+
guard let id = initialStickerId,
|
|
165
|
+
let asset = consumerStickerAssets().first(where: { $0.id == id }) else { return }
|
|
166
|
+
view.isUserInteractionEnabled = false
|
|
167
|
+
let loader = OnlineStickerSheetViewController()
|
|
168
|
+
loader.loadRuntimeSticker(.init(id: id, uri: asset.uri)) { [weak self, loader] url, image in
|
|
169
|
+
_ = loader // Keep the loader alive until its download finishes.
|
|
170
|
+
guard let self else { return }
|
|
171
|
+
self.view.isUserInteractionEnabled = true
|
|
172
|
+
guard let url, let image else {
|
|
173
|
+
self.completion?(.failure(code: "E_SOURCE_UNREADABLE", message: "The initial sticker could not be loaded."))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
self.view.layoutIfNeeded()
|
|
177
|
+
self.handlePickedImage(url, image: image, purpose: self.mediaType == "photo" ? .photoSticker : .videoSticker)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
182
|
+
super.viewDidAppear(animated)
|
|
183
|
+
if mediaType == "photo" {
|
|
184
|
+
photoImageView?.resetToFit()
|
|
185
|
+
schedulePhotoPreviewRender()
|
|
186
|
+
}
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
// MARK: - Photo editor (crop, rotate, straighten, export — Milestone 2)
|
|
@@ -176,7 +209,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
176
209
|
return
|
|
177
210
|
}
|
|
178
211
|
|
|
179
|
-
let imageView = ZoomableImageView()
|
|
212
|
+
let imageView = ZoomableImageView(frame: .zero)
|
|
180
213
|
imageView.image = session.renderPreview() ?? baseImage
|
|
181
214
|
photoImageView = imageView
|
|
182
215
|
preview.addSubview(imageView)
|
|
@@ -247,9 +280,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
247
280
|
layers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
248
281
|
guard let self, let session = self.photoSession else { return }
|
|
249
282
|
if self.dragStartSnapshot == nil { self.dragStartSnapshot = session.layerStack.layers }
|
|
250
|
-
session.layerStack.updateLive { list in
|
|
251
|
-
|
|
252
|
-
|
|
283
|
+
session.layerStack.updateLive { list in
|
|
284
|
+
list.map { current in
|
|
285
|
+
guard current.id == id else { return current }
|
|
286
|
+
var layer = current
|
|
287
|
+
layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation
|
|
288
|
+
return layer
|
|
289
|
+
}
|
|
290
|
+
}
|
|
253
291
|
layers.layers = session.layerStack.layers
|
|
254
292
|
self.schedulePhotoPreviewRender()
|
|
255
293
|
}
|
|
@@ -330,7 +368,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
330
368
|
filtersBar.heightAnchor.constraint(equalToConstant: EditorPanelMetrics.totalHeight + 24),
|
|
331
369
|
stickersBar.heightAnchor.constraint(equalToConstant: 56),
|
|
332
370
|
drawBar.heightAnchor.constraint(equalToConstant: 56),
|
|
333
|
-
|
|
371
|
+
layerSlider.heightAnchor.constraint(equalToConstant: 64),
|
|
372
|
+
layerBar.heightAnchor.constraint(equalToConstant: 68),
|
|
334
373
|
colorSwatchRow.heightAnchor.constraint(equalToConstant: 56),
|
|
335
374
|
])
|
|
336
375
|
|
|
@@ -360,75 +399,122 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
360
399
|
imageView.resetToFit()
|
|
361
400
|
}
|
|
362
401
|
|
|
363
|
-
///
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
let tile
|
|
402
|
+
/// Interactive tool dock button with tactile feedback, balanced icon/label spacing,
|
|
403
|
+
/// and seamless scroll coordination without conflicting gesture recognizers.
|
|
404
|
+
private final class ToolButton: UIControl {
|
|
405
|
+
let tile = UIView()
|
|
367
406
|
let icon: UIImageView
|
|
368
|
-
let label
|
|
407
|
+
let label = UILabel()
|
|
408
|
+
private var onTapAction: (() -> Void)?
|
|
409
|
+
|
|
410
|
+
init(systemName: String, labelText: String, onTap: (() -> Void)? = nil) {
|
|
411
|
+
self.onTapAction = onTap
|
|
412
|
+
self.icon = UIImageView(image: UIImage(systemName: systemName))
|
|
413
|
+
super.init(frame: .zero)
|
|
414
|
+
setupViews(labelText: labelText)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
418
|
+
|
|
419
|
+
private func setupViews(labelText: String) {
|
|
420
|
+
isAccessibilityElement = true
|
|
421
|
+
accessibilityLabel = labelText
|
|
422
|
+
accessibilityTraits = .button
|
|
423
|
+
|
|
424
|
+
tile.isUserInteractionEnabled = false
|
|
425
|
+
tile.backgroundColor = .clear
|
|
426
|
+
tile.layer.cornerRadius = 15
|
|
427
|
+
tile.clipsToBounds = true
|
|
428
|
+
|
|
429
|
+
icon.isUserInteractionEnabled = false
|
|
430
|
+
icon.tintColor = DesignTokens.textSecondary
|
|
431
|
+
icon.contentMode = .scaleAspectFit
|
|
432
|
+
|
|
433
|
+
tile.addSubview(icon)
|
|
434
|
+
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
435
|
+
NSLayoutConstraint.activate([
|
|
436
|
+
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
437
|
+
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
|
438
|
+
icon.widthAnchor.constraint(equalToConstant: 18),
|
|
439
|
+
icon.heightAnchor.constraint(equalToConstant: 18),
|
|
440
|
+
])
|
|
441
|
+
|
|
442
|
+
label.isUserInteractionEnabled = false
|
|
443
|
+
label.text = labelText
|
|
444
|
+
label.font = .systemFont(ofSize: 11, weight: .medium)
|
|
445
|
+
label.textColor = DesignTokens.textSecondary
|
|
446
|
+
label.textAlignment = .center
|
|
447
|
+
label.numberOfLines = 1
|
|
448
|
+
label.adjustsFontSizeToFitWidth = true
|
|
449
|
+
label.minimumScaleFactor = 0.85
|
|
450
|
+
|
|
451
|
+
let contentStack = UIStackView(arrangedSubviews: [tile, label])
|
|
452
|
+
contentStack.axis = .vertical
|
|
453
|
+
contentStack.alignment = .center
|
|
454
|
+
contentStack.spacing = 3
|
|
455
|
+
contentStack.isUserInteractionEnabled = false
|
|
456
|
+
|
|
457
|
+
addSubview(contentStack)
|
|
458
|
+
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
459
|
+
NSLayoutConstraint.activate([
|
|
460
|
+
tile.widthAnchor.constraint(equalToConstant: 30),
|
|
461
|
+
tile.heightAnchor.constraint(equalToConstant: 30),
|
|
462
|
+
contentStack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
463
|
+
contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
464
|
+
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 2),
|
|
465
|
+
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -2),
|
|
466
|
+
])
|
|
467
|
+
|
|
468
|
+
if onTapAction != nil {
|
|
469
|
+
addTarget(self, action: #selector(handleTap), for: .touchUpInside)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@objc private func handleTap() {
|
|
474
|
+
onTapAction?()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
override var isHighlighted: Bool {
|
|
478
|
+
didSet {
|
|
479
|
+
UIView.animate(
|
|
480
|
+
withDuration: isHighlighted ? 0.08 : 0.14,
|
|
481
|
+
delay: 0,
|
|
482
|
+
options: [.allowUserInteraction, .beginFromCurrentState],
|
|
483
|
+
animations: { self.transform = self.isHighlighted ? CGAffineTransform(scaleX: 0.94, y: 0.94) : .identity }
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
func setSelectedState(_ selected: Bool, accent: UIColor) {
|
|
489
|
+
accessibilityTraits = selected ? [.button, .selected] : .button
|
|
490
|
+
tile.backgroundColor = selected ? accent.withAlphaComponent(0.18) : .clear
|
|
491
|
+
icon.tintColor = selected ? accent : DesignTokens.textSecondary
|
|
492
|
+
label.textColor = selected ? accent : DesignTokens.textSecondary
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Icon-tile + label node in the main tool rail (Studio Violet "Tool Node").
|
|
497
|
+
private struct ToolNode {
|
|
498
|
+
let button: ToolButton
|
|
499
|
+
var root: UIView { button }
|
|
500
|
+
var tile: UIView { button.tile }
|
|
501
|
+
var icon: UIImageView { button.icon }
|
|
502
|
+
var label: UILabel { button.label }
|
|
369
503
|
}
|
|
370
504
|
|
|
371
505
|
private var photoToolNodes: [String: ToolNode] = [:]
|
|
372
506
|
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
507
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
private func createToolNode(systemName: String, labelText: String, onTap: @escaping () -> Void) -> ToolNode {
|
|
383
|
-
let icon = UIImageView(image: UIImage(systemName: systemName))
|
|
384
|
-
icon.tintColor = DesignTokens.textSecondary
|
|
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)
|
|
508
|
+
private func createToolNode(systemName: String, labelText: String, onTap: (() -> Void)? = nil) -> ToolNode {
|
|
509
|
+
let button = ToolButton(systemName: systemName, labelText: labelText, onTap: onTap)
|
|
510
|
+
return ToolNode(button: button)
|
|
422
511
|
}
|
|
423
512
|
|
|
424
513
|
/// Selection is a purple icon + label over a faint purple disc — not a filled tile, which at dock
|
|
425
514
|
/// size reads as a large button and fights the photo for attention.
|
|
426
515
|
private func setToolNodeSelected(_ node: ToolNode, selected: Bool, textColor: UIColor) {
|
|
427
516
|
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
|
|
517
|
+
node.button.setSelectedState(selected, accent: accent)
|
|
432
518
|
}
|
|
433
519
|
|
|
434
520
|
/// Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside
|
|
@@ -436,43 +522,53 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
436
522
|
private func makePhotoToolBar(textColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
437
523
|
let tools: [(String, String, String)] = [
|
|
438
524
|
("crop", "Crop", "crop"),
|
|
525
|
+
("rotate", "Rotate", "rotate.right"),
|
|
439
526
|
("adjust", "Adjust", "slider.horizontal.3"),
|
|
440
527
|
("filters", "Filters", "camera.filters"),
|
|
441
528
|
("text", "Text", "textformat"),
|
|
442
529
|
("stickers", "Stickers", "face.smiling"),
|
|
443
530
|
("draw", "Draw", "pencil.tip"),
|
|
531
|
+
("overlay", "Overlay", "plus.rectangle.on.rectangle"),
|
|
444
532
|
("resize", "Resize", "aspectratio"),
|
|
445
533
|
]
|
|
446
|
-
let scroll = UIScrollView()
|
|
534
|
+
let scroll = UIScrollView()
|
|
535
|
+
scroll.backgroundColor = toolbarColor
|
|
447
536
|
scroll.showsHorizontalScrollIndicator = false
|
|
537
|
+
scroll.alwaysBounceHorizontal = true
|
|
538
|
+
scroll.delaysContentTouches = true
|
|
539
|
+
scroll.canCancelContentTouches = true
|
|
448
540
|
photoToolScroll = scroll
|
|
449
|
-
let stack = UIStackView()
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let itemWidth =
|
|
453
|
-
tools.filter {
|
|
541
|
+
let stack = UIStackView()
|
|
542
|
+
stack.axis = .horizontal
|
|
543
|
+
stack.spacing = 2
|
|
544
|
+
let itemWidth: CGFloat = 58
|
|
545
|
+
tools.filter { key, _, _ in
|
|
546
|
+
if key == "overlay" {
|
|
547
|
+
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
548
|
+
}
|
|
549
|
+
return features[key] as? Bool != false
|
|
550
|
+
}.forEach { key, label, symbol in
|
|
454
551
|
let node = createToolNode(systemName: symbol, labelText: label) { [weak self] in self?.onPhotoToolTapped(key) }
|
|
455
|
-
node.
|
|
552
|
+
node.button.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
456
553
|
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)
|
|
554
|
+
stack.addArrangedSubview(node.button)
|
|
555
|
+
}
|
|
556
|
+
// "Original" compare button uses native touch-down / touch-up events without gesture recognizers.
|
|
557
|
+
let compare = createToolNode(systemName: "rectangle.split.2x1", labelText: "Original")
|
|
558
|
+
compare.button.accessibilityLabel = "Hold to compare with original"
|
|
559
|
+
compare.button.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
560
|
+
compare.button.addTarget(self, action: #selector(compareTouchDown), for: .touchDown)
|
|
561
|
+
compare.button.addTarget(self, action: #selector(compareTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
|
467
562
|
photoToolNodes["compare"] = compare
|
|
468
|
-
stack.addArrangedSubview(compare.
|
|
563
|
+
stack.addArrangedSubview(compare.button)
|
|
564
|
+
|
|
469
565
|
scroll.addSubview(stack)
|
|
470
566
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
471
567
|
NSLayoutConstraint.activate([
|
|
472
568
|
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
473
|
-
|
|
569
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
|
|
474
570
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
475
|
-
|
|
571
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
476
572
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
477
573
|
])
|
|
478
574
|
return scroll
|
|
@@ -504,6 +600,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
504
600
|
switch key {
|
|
505
601
|
case "crop":
|
|
506
602
|
setCropMode(true)
|
|
603
|
+
case "rotate":
|
|
604
|
+
session.update { $0.rotateRight(); $0.zoom = 1; $0.panX = 0; $0.panY = 0 }
|
|
605
|
+
photoCropPanel?.sync(session.state)
|
|
606
|
+
schedulePhotoPreviewRender()
|
|
507
607
|
case "adjust":
|
|
508
608
|
setAdjustMode(true, session: session)
|
|
509
609
|
case "filters":
|
|
@@ -516,6 +616,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
516
616
|
presentOnlineStickerSheet(purpose: .photoSticker)
|
|
517
617
|
case "draw":
|
|
518
618
|
setDrawMode(true, session: session)
|
|
619
|
+
case "overlay", "overlays":
|
|
620
|
+
presentImagePicker(purpose: .photoOverlay)
|
|
519
621
|
case "resize":
|
|
520
622
|
setCropMode(true)
|
|
521
623
|
default:
|
|
@@ -557,6 +659,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
557
659
|
private func makeStickersSubBar(session: PhotoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
558
660
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
559
661
|
let scroll = UIScrollView()
|
|
662
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
663
|
+
scroll.alwaysBounceHorizontal = true
|
|
664
|
+
scroll.delaysContentTouches = true
|
|
665
|
+
scroll.canCancelContentTouches = true
|
|
560
666
|
let chipStack = UIStackView(); chipStack.axis = .horizontal; chipStack.spacing = 4
|
|
561
667
|
PhotoLayerRenderer.builtinStickerIDs().forEach { id in
|
|
562
668
|
chipStack.addArrangedSubview(button(PhotoLayerRenderer.glyph(for: id)) { [weak self] in
|
|
@@ -586,9 +692,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
586
692
|
chipStack.translatesAutoresizingMaskIntoConstraints = false
|
|
587
693
|
NSLayoutConstraint.activate([
|
|
588
694
|
chipStack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
589
|
-
|
|
695
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: chipStack.trailingAnchor, constant: 8),
|
|
590
696
|
chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
591
|
-
|
|
697
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: chipStack.bottomAnchor),
|
|
592
698
|
chipStack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
593
699
|
])
|
|
594
700
|
bar.addArrangedSubview(scroll)
|
|
@@ -607,8 +713,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
607
713
|
layerOverlay?.isHidden = enabled
|
|
608
714
|
photoImageView?.panZoomEnabled = !enabled
|
|
609
715
|
if enabled {
|
|
610
|
-
drawOverlay?.strokeColor = .
|
|
716
|
+
drawOverlay?.strokeColor = .systemRed
|
|
611
717
|
drawOverlay?.strokeWidthPx = 4
|
|
718
|
+
updateDrawSwatches(selected: .systemRed)
|
|
612
719
|
if let bounds = photoImageView?.currentImageBounds() { drawOverlay?.setImageBounds(bounds) }
|
|
613
720
|
} else {
|
|
614
721
|
commitDrawStrokes(session: session)
|
|
@@ -636,21 +743,53 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
636
743
|
}
|
|
637
744
|
|
|
638
745
|
private func makeDrawSubBar(textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
639
|
-
|
|
746
|
+
drawColorSwatches.removeAll()
|
|
747
|
+
let colors: [(String, UIColor)] = [
|
|
748
|
+
("Red", .systemRed),
|
|
749
|
+
("Yellow", .systemYellow),
|
|
750
|
+
("Green", .systemGreen),
|
|
751
|
+
("Blue", .systemBlue),
|
|
752
|
+
("Purple", DesignTokens.primary),
|
|
753
|
+
("Pink", .systemPink),
|
|
754
|
+
("White", .white),
|
|
755
|
+
("Black", .black),
|
|
756
|
+
]
|
|
640
757
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
641
758
|
let scroll = UIScrollView()
|
|
642
|
-
|
|
643
|
-
|
|
759
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
760
|
+
scroll.alwaysBounceHorizontal = true
|
|
761
|
+
scroll.delaysContentTouches = true
|
|
762
|
+
scroll.canCancelContentTouches = true
|
|
763
|
+
let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 6
|
|
764
|
+
controls.alignment = .center
|
|
765
|
+
|
|
766
|
+
colors.forEach { name, color in
|
|
767
|
+
let swatch = createDrawColorSwatch(color: color) { [weak self] in
|
|
768
|
+
self?.drawOverlay?.strokeColor = color
|
|
769
|
+
self?.updateDrawSwatches(selected: color)
|
|
770
|
+
}
|
|
771
|
+
swatch.accessibilityLabel = name
|
|
772
|
+
drawColorSwatches.append((color, swatch))
|
|
773
|
+
controls.addArrangedSubview(swatch)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
let divider = UIView()
|
|
777
|
+
divider.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
778
|
+
divider.widthAnchor.constraint(equalToConstant: 1).isActive = true
|
|
779
|
+
divider.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
780
|
+
controls.addArrangedSubview(divider)
|
|
781
|
+
|
|
644
782
|
controls.addArrangedSubview(button("Thin", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 2 })
|
|
645
783
|
controls.addArrangedSubview(button("Thick", color: textColor) { [weak self] in self?.drawOverlay?.strokeWidthPx = 10 })
|
|
646
784
|
controls.addArrangedSubview(button("Undo", color: textColor) { [weak self] in self?.drawOverlay?.undoLastStroke() })
|
|
647
785
|
controls.addArrangedSubview(button("Clear", color: textColor) { [weak self] in self?.drawOverlay?.clearStrokes() })
|
|
786
|
+
|
|
648
787
|
scroll.addSubview(controls); controls.translatesAutoresizingMaskIntoConstraints = false
|
|
649
788
|
NSLayoutConstraint.activate([
|
|
650
789
|
controls.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
651
|
-
|
|
790
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 8),
|
|
652
791
|
controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
653
|
-
|
|
792
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: controls.bottomAnchor),
|
|
654
793
|
controls.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
655
794
|
])
|
|
656
795
|
bar.addArrangedSubview(scroll)
|
|
@@ -661,9 +800,43 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
661
800
|
bar.backgroundColor = toolbarColor
|
|
662
801
|
bar.isLayoutMarginsRelativeArrangement = true
|
|
663
802
|
bar.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
|
803
|
+
updateDrawSwatches(selected: .systemRed)
|
|
664
804
|
return bar
|
|
665
805
|
}
|
|
666
806
|
|
|
807
|
+
private func createDrawColorSwatch(color: UIColor, onTap: @escaping () -> Void) -> UIButton {
|
|
808
|
+
let outer = UIButton(type: .custom)
|
|
809
|
+
outer.backgroundColor = DesignTokens.surfaceContainerHigh
|
|
810
|
+
outer.layer.cornerRadius = 16
|
|
811
|
+
outer.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
812
|
+
outer.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
813
|
+
let inner = UIView()
|
|
814
|
+
inner.backgroundColor = color
|
|
815
|
+
inner.layer.cornerRadius = 10
|
|
816
|
+
inner.isUserInteractionEnabled = false
|
|
817
|
+
inner.translatesAutoresizingMaskIntoConstraints = false
|
|
818
|
+
outer.addSubview(inner)
|
|
819
|
+
NSLayoutConstraint.activate([
|
|
820
|
+
inner.centerXAnchor.constraint(equalTo: outer.centerXAnchor),
|
|
821
|
+
inner.centerYAnchor.constraint(equalTo: outer.centerYAnchor),
|
|
822
|
+
inner.widthAnchor.constraint(equalToConstant: 20),
|
|
823
|
+
inner.heightAnchor.constraint(equalToConstant: 20),
|
|
824
|
+
])
|
|
825
|
+
outer.addAction(UIAction { _ in onTap() }, for: .touchUpInside)
|
|
826
|
+
outer.applyPressScale()
|
|
827
|
+
return outer
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private func updateDrawSwatches(selected: UIColor) {
|
|
831
|
+
let primary = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
832
|
+
drawColorSwatches.forEach { color, swatch in
|
|
833
|
+
let isSel = color == selected
|
|
834
|
+
swatch.layer.borderWidth = isSel ? 2 : 0
|
|
835
|
+
swatch.layer.borderColor = primary.cgColor
|
|
836
|
+
swatch.accessibilityTraits = isSel ? [.button, .selected] : .button
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
667
840
|
private func consumerStickerAssets() -> [(id: String, uri: String)] {
|
|
668
841
|
stickerAssets.compactMap { asset in
|
|
669
842
|
guard let uri = asset["uri"] as? String, !uri.isEmpty else { return nil }
|
|
@@ -776,7 +949,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
776
949
|
|
|
777
950
|
/// Keeps the active tool-rail node fully visible, never partially clipped at the scroll edge.
|
|
778
951
|
private func scrollToolNodeIntoView(_ node: ToolNode) {
|
|
779
|
-
guard let scroll = photoToolScroll else { return }
|
|
952
|
+
guard let scroll = photoToolScroll, !cropMode, !scroll.isHidden else { return }
|
|
780
953
|
DispatchQueue.main.async {
|
|
781
954
|
let frame = node.root.convert(node.root.bounds, to: scroll)
|
|
782
955
|
scroll.scrollRectToVisible(frame.insetBy(dx: -DesignTokens.spaceSm, dy: 0), animated: true)
|
|
@@ -784,7 +957,10 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
784
957
|
}
|
|
785
958
|
|
|
786
959
|
private func selectLayer(_ id: String?) {
|
|
787
|
-
if selectedLayerID != id {
|
|
960
|
+
if selectedLayerID != id {
|
|
961
|
+
let isSticker = photoSession?.layerStack.layers.first(where: { $0.id == id })?.type == .sticker
|
|
962
|
+
activeLayerPropertyKey = isSticker ? "opacity" : ""
|
|
963
|
+
}
|
|
788
964
|
selectedLayerID = id
|
|
789
965
|
layerOverlay?.selectedLayerID = id
|
|
790
966
|
if let session = photoSession {
|
|
@@ -804,9 +980,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
804
980
|
layerToolBar?.isHidden = !visible
|
|
805
981
|
mainToolBar?.isHidden = visible
|
|
806
982
|
let isText = currentSelectedLayer()?.type == .text
|
|
807
|
-
let
|
|
983
|
+
let isSticker = currentSelectedLayer()?.type == .sticker
|
|
984
|
+
let simple = isText || isSticker
|
|
808
985
|
["scale", "lock", "visibility", "front", "back"].forEach { layerPropertyButtons[$0]?.isHidden = simple }
|
|
809
986
|
["edit", "color", "fontSize"].forEach { layerPropertyButtons[$0]?.isHidden = !isText }
|
|
987
|
+
if isSticker && activeLayerPropertyKey.isEmpty { activeLayerPropertyKey = "opacity" }
|
|
810
988
|
if !isText && ["color", "fontSize"].contains(activeLayerPropertyKey) { activeLayerPropertyKey = "" }
|
|
811
989
|
layerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeLayerPropertyKey)
|
|
812
990
|
layerColorSwatchRow?.isHidden = !visible || activeLayerPropertyKey != "color"
|
|
@@ -906,18 +1084,18 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
906
1084
|
let control = UIButton(type: .system)
|
|
907
1085
|
var config = UIButton.Configuration.plain()
|
|
908
1086
|
config.title = label
|
|
909
|
-
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize:
|
|
1087
|
+
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))
|
|
910
1088
|
config.imagePlacement = .top
|
|
911
|
-
config.imagePadding =
|
|
1089
|
+
config.imagePadding = 4
|
|
912
1090
|
config.baseForegroundColor = DesignTokens.outline
|
|
913
1091
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
914
|
-
var value = incoming; value.font = .systemFont(ofSize:
|
|
1092
|
+
var value = incoming; value.font = .systemFont(ofSize: 10, weight: .medium); return value
|
|
915
1093
|
}
|
|
916
|
-
config.contentInsets = NSDirectionalEdgeInsets(top:
|
|
1094
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 2, bottom: 6, trailing: 2)
|
|
917
1095
|
control.configuration = config
|
|
918
1096
|
control.accessibilityLabel = label
|
|
919
|
-
let width = control.widthAnchor.constraint(equalToConstant:
|
|
920
|
-
control.heightAnchor.constraint(equalToConstant:
|
|
1097
|
+
let width = control.widthAnchor.constraint(equalToConstant: 54); width.priority = .defaultHigh; width.isActive = true
|
|
1098
|
+
control.heightAnchor.constraint(equalToConstant: 68).isActive = true
|
|
921
1099
|
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
922
1100
|
control.applyPressScale()
|
|
923
1101
|
return control
|
|
@@ -937,8 +1115,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
937
1115
|
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
1116
|
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
1117
|
let bar = UIStackView(); bar.axis = .horizontal; bar.alignment = .center
|
|
940
|
-
let scroll = UIScrollView()
|
|
941
|
-
|
|
1118
|
+
let scroll = UIScrollView()
|
|
1119
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
1120
|
+
scroll.alwaysBounceHorizontal = true
|
|
1121
|
+
scroll.delaysContentTouches = true
|
|
1122
|
+
scroll.canCancelContentTouches = true
|
|
1123
|
+
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 2
|
|
942
1124
|
actions.forEach { key, label, symbol in
|
|
943
1125
|
let control = toolButton(label, symbol: symbol) { action(key) }
|
|
944
1126
|
if video { videoLayerPropertyButtons[key] = control } else { layerPropertyButtons[key] = control }
|
|
@@ -946,12 +1128,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
946
1128
|
}
|
|
947
1129
|
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
948
1130
|
NSLayoutConstraint.activate([
|
|
949
|
-
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant:
|
|
950
|
-
|
|
1131
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 6),
|
|
1132
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 6),
|
|
951
1133
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
952
|
-
|
|
1134
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
953
1135
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
954
|
-
scroll.heightAnchor.constraint(equalToConstant:
|
|
1136
|
+
scroll.heightAnchor.constraint(equalToConstant: 68),
|
|
955
1137
|
])
|
|
956
1138
|
bar.addArrangedSubview(scroll)
|
|
957
1139
|
let done = toolButton("Done", symbol: "checkmark") { action("done") }
|
|
@@ -1002,7 +1184,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1002
1184
|
}
|
|
1003
1185
|
|
|
1004
1186
|
private func makeTextPalette(video: Bool, onColor: @escaping (UIColor) -> Void) -> UIView {
|
|
1005
|
-
let scroll = UIScrollView()
|
|
1187
|
+
let scroll = UIScrollView()
|
|
1188
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
1189
|
+
scroll.alwaysBounceHorizontal = true
|
|
1190
|
+
scroll.delaysContentTouches = true
|
|
1191
|
+
scroll.canCancelContentTouches = true
|
|
1006
1192
|
scroll.backgroundColor = color("toolbarColor") ?? DesignTokens.surfaceContainerLow
|
|
1007
1193
|
let stack = UIStackView(); stack.axis = .horizontal; stack.spacing = 4
|
|
1008
1194
|
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 +1201,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1015
1201
|
scroll.addSubview(stack); stack.translatesAutoresizingMaskIntoConstraints = false
|
|
1016
1202
|
NSLayoutConstraint.activate([
|
|
1017
1203
|
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
|
|
1018
|
-
|
|
1204
|
+
scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
|
|
1019
1205
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
1020
|
-
|
|
1206
|
+
scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
|
1021
1207
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
1022
1208
|
])
|
|
1023
1209
|
return scroll
|
|
@@ -1183,7 +1369,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1183
1369
|
)
|
|
1184
1370
|
|
|
1185
1371
|
let strip = UIStackView()
|
|
1186
|
-
strip.spacing =
|
|
1372
|
+
strip.spacing = 2
|
|
1187
1373
|
photoAdjustmentDefinitions.forEach { key, label, symbol in
|
|
1188
1374
|
let item = editorStripItem(systemName: symbol, labelText: label) { [weak self] in
|
|
1189
1375
|
self?.activeAdjustmentKey = key
|
|
@@ -1290,7 +1476,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1290
1476
|
}
|
|
1291
1477
|
|
|
1292
1478
|
@objc private func straightenChanged(_ slider: UISlider) {
|
|
1293
|
-
guard let session = photoSession,
|
|
1479
|
+
guard let session = photoSession, photoImageView != nil else { return }
|
|
1294
1480
|
session.update { $0.setStraighten(CGFloat(slider.value)) }
|
|
1295
1481
|
schedulePhotoPreviewRender()
|
|
1296
1482
|
}
|
|
@@ -1403,9 +1589,14 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1403
1589
|
videoLayers.onLayerTransformChanged = { [weak self] id, x, y, scale, rotation in
|
|
1404
1590
|
guard let self else { return }
|
|
1405
1591
|
if self.videoDragStartSnapshot == nil { self.videoDragStartSnapshot = session.layerStack.layers }
|
|
1406
|
-
session.layerStack.updateLive { list in
|
|
1407
|
-
|
|
1408
|
-
|
|
1592
|
+
session.layerStack.updateLive { list in
|
|
1593
|
+
list.map { current in
|
|
1594
|
+
guard current.id == id else { return current }
|
|
1595
|
+
var layer = current
|
|
1596
|
+
layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation
|
|
1597
|
+
return layer
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1409
1600
|
videoLayers.layers = session.layerStack.layers
|
|
1410
1601
|
self.refreshVideoOverlayPreview(session: session)
|
|
1411
1602
|
}
|
|
@@ -1592,10 +1783,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1592
1783
|
toolbar.bottomAnchor.constraint(equalTo: toolbarWrapper.bottomAnchor, constant: -DesignTokens.spaceSm),
|
|
1593
1784
|
])
|
|
1594
1785
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1786
|
+
let trimEnabled = features["trim"] as? Bool != false
|
|
1787
|
+
trim.isHidden = !trimEnabled
|
|
1788
|
+
let root = UIStackView(arrangedSubviews: [header, previewWrapper, trim, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1599
1789
|
root.axis = .vertical
|
|
1600
1790
|
root.spacing = DesignTokens.spaceSm
|
|
1601
1791
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -1607,9 +1797,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1607
1797
|
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
1608
1798
|
header.heightAnchor.constraint(equalToConstant: 60),
|
|
1609
1799
|
trim.heightAnchor.constraint(equalToConstant: 48),
|
|
1610
|
-
stripScroll.heightAnchor.constraint(equalToConstant: 40),
|
|
1611
1800
|
aspectBar.heightAnchor.constraint(equalToConstant: 56),
|
|
1612
|
-
layerBar.heightAnchor.constraint(equalToConstant:
|
|
1801
|
+
layerBar.heightAnchor.constraint(equalToConstant: 68),
|
|
1613
1802
|
toolbar.heightAnchor.constraint(equalToConstant: 52),
|
|
1614
1803
|
])
|
|
1615
1804
|
|
|
@@ -1744,10 +1933,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1744
1933
|
}
|
|
1745
1934
|
|
|
1746
1935
|
/// Where the video actually renders within `view` under aspect-fit letterboxing (mirrors `ZoomableImageView.currentImageBounds()`'s math).
|
|
1747
|
-
private func videoSourceSize(session:VideoEditSession) -> CGSize {
|
|
1748
|
-
|
|
1936
|
+
private func videoSourceSize(session: VideoEditSession) -> CGSize {
|
|
1937
|
+
if session.naturalSize != .zero { return session.naturalSize }
|
|
1938
|
+
guard let clip = session.clips.first, let track = session.asset(for: clip).tracks(withMediaType: .video).first else { return CGSize(width: 1, height: 1) }
|
|
1749
1939
|
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1750
|
-
return CGSize(width:max(1,abs(size.width)),height:max(1,abs(size.height)))
|
|
1940
|
+
return CGSize(width: max(1, abs(size.width)), height: max(1, abs(size.height)))
|
|
1751
1941
|
}
|
|
1752
1942
|
|
|
1753
1943
|
private func videoMediaBounds(session:VideoEditSession,in view:UIView,cropped:Bool) -> CGRect {
|
|
@@ -1919,20 +2109,48 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1919
2109
|
/// actions warrant. `textformat` renders as "Aa", which reads as typography where a lone "T" does not.
|
|
1920
2110
|
private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController) -> UIView {
|
|
1921
2111
|
let tools: [(String, String, String)] = [
|
|
1922
|
-
("
|
|
2112
|
+
("trim", "Trim", "arrow.left.and.right"),
|
|
2113
|
+
("crop", "Crop", "crop"),
|
|
2114
|
+
("rotate", "Rotate", "rotate.right"),
|
|
2115
|
+
("speed", "Speed", "gauge.with.dots.needle.50percent"),
|
|
2116
|
+
("text", "Text", "textformat"),
|
|
2117
|
+
("stickers", "Stickers", "face.smiling"),
|
|
2118
|
+
("overlay", "Overlay", "plus.rectangle.on.rectangle"),
|
|
2119
|
+
("cover", "Cover", "photo"),
|
|
1923
2120
|
]
|
|
1924
|
-
let
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2121
|
+
let scroll = UIScrollView()
|
|
2122
|
+
scroll.backgroundColor = DesignTokens.surfaceContainer
|
|
2123
|
+
scroll.layer.cornerRadius = 18
|
|
2124
|
+
scroll.clipsToBounds = true
|
|
2125
|
+
scroll.showsHorizontalScrollIndicator = false
|
|
2126
|
+
|
|
2127
|
+
let stack = UIStackView()
|
|
2128
|
+
stack.axis = .horizontal
|
|
2129
|
+
stack.spacing = DesignTokens.spaceXs
|
|
2130
|
+
|
|
2131
|
+
let itemWidth = Swift.min(Swift.max(UIScreen.main.bounds.width / 4.5, 76), 96)
|
|
2132
|
+
tools.filter { key, _, _ in
|
|
2133
|
+
if key == "overlay" {
|
|
2134
|
+
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
2135
|
+
}
|
|
2136
|
+
return features[key] as? Bool != false
|
|
2137
|
+
}.forEach { key, label, symbol in
|
|
2138
|
+
let control = makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
|
|
1932
2139
|
self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
|
|
1933
|
-
}
|
|
2140
|
+
}
|
|
2141
|
+
control.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
|
|
2142
|
+
stack.addArrangedSubview(control)
|
|
1934
2143
|
}
|
|
1935
|
-
|
|
2144
|
+
scroll.addSubview(stack)
|
|
2145
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
2146
|
+
NSLayoutConstraint.activate([
|
|
2147
|
+
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
|
|
2148
|
+
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
|
|
2149
|
+
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
2150
|
+
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
2151
|
+
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
|
2152
|
+
])
|
|
2153
|
+
return scroll
|
|
1936
2154
|
}
|
|
1937
2155
|
|
|
1938
2156
|
/// One dock action: symbol + label on a single row, purple pill while pressed.
|
|
@@ -1975,7 +2193,11 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1975
2193
|
|
|
1976
2194
|
private func onVideoToolTapped(_ key: String, session: VideoEditSession, playerView: UIView?) {
|
|
1977
2195
|
switch key {
|
|
1978
|
-
case "
|
|
2196
|
+
case "trim":
|
|
2197
|
+
guard let trim = trimRangeView else { return }
|
|
2198
|
+
trim.isHidden.toggle()
|
|
2199
|
+
case "crop":
|
|
2200
|
+
setVideoCropMode(true, session: session)
|
|
1979
2201
|
case "cover":
|
|
1980
2202
|
let atMs = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
|
|
1981
2203
|
session.update { $0.coverFrameMs = atMs }
|
|
@@ -1983,17 +2205,17 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1983
2205
|
case "rotate":
|
|
1984
2206
|
session.update { $0.rotateRight() }
|
|
1985
2207
|
applyVideoPreviewTransform(playerView, state: session.state)
|
|
1986
|
-
case "
|
|
2208
|
+
case "aspect", "resize":
|
|
1987
2209
|
setVideoAspectMode(true, session: session)
|
|
1988
2210
|
case "speed":
|
|
1989
2211
|
session.update { $0.cycleSpeed() }
|
|
1990
2212
|
if session.player.timeControlStatus == .playing { session.player.rate = session.state.speed }
|
|
1991
|
-
|
|
2213
|
+
showToast(formatSpeedLabel(session.state.speed))
|
|
1992
2214
|
case "text":
|
|
1993
2215
|
showVideoTextInputDialog(session: session)
|
|
1994
2216
|
case "stickers":
|
|
1995
2217
|
showVideoStickerPicker(session: session)
|
|
1996
|
-
case "overlay":
|
|
2218
|
+
case "overlay", "overlays":
|
|
1997
2219
|
presentImagePicker(purpose: .videoOverlay)
|
|
1998
2220
|
default:
|
|
1999
2221
|
showToast("\(key) is coming in a later milestone.")
|
|
@@ -2408,20 +2630,46 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2408
2630
|
present(sheet, animated: true)
|
|
2409
2631
|
}
|
|
2410
2632
|
|
|
2411
|
-
/// Coalesces slider and drag callbacks
|
|
2633
|
+
/// Coalesces slider and drag callbacks and performs rendering on a dedicated background queue
|
|
2634
|
+
/// so the main thread remains at 120 FPS without slider hitching or dragging lag.
|
|
2412
2635
|
private func schedulePhotoPreviewRender() {
|
|
2413
|
-
|
|
2414
|
-
|
|
2636
|
+
photoRenderQueue.async { [weak self] in
|
|
2637
|
+
guard let self else { return }
|
|
2638
|
+
if self.isRenderingPhotoPreview {
|
|
2639
|
+
self.photoPreviewRenderPending = true
|
|
2640
|
+
return
|
|
2641
|
+
}
|
|
2642
|
+
self.isRenderingPhotoPreview = true
|
|
2643
|
+
self.performPhotoPreviewRender()
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
private func performPhotoPreviewRender() {
|
|
2648
|
+
guard self.isViewLoaded,
|
|
2649
|
+
let session = self.photoSession else {
|
|
2650
|
+
self.isRenderingPhotoPreview = false
|
|
2651
|
+
return
|
|
2652
|
+
}
|
|
2653
|
+
let isCropping = self.cropMode
|
|
2654
|
+
let rendered = autoreleasepool {
|
|
2655
|
+
session.renderPreview(cropping: isCropping)
|
|
2656
|
+
}
|
|
2415
2657
|
DispatchQueue.main.async { [weak self] in
|
|
2416
2658
|
guard let self else { return }
|
|
2417
|
-
self.
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2659
|
+
if let rendered, let imageView = self.photoImageView {
|
|
2660
|
+
imageView.image = rendered
|
|
2661
|
+
if self.cropMode {
|
|
2662
|
+
self.cropOverlay?.restore(bounds: imageView.currentImageBounds(), state: session.state)
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
self.photoRenderQueue.async { [weak self] in
|
|
2666
|
+
guard let self else { return }
|
|
2667
|
+
if self.photoPreviewRenderPending {
|
|
2668
|
+
self.photoPreviewRenderPending = false
|
|
2669
|
+
self.performPhotoPreviewRender()
|
|
2670
|
+
} else {
|
|
2671
|
+
self.isRenderingPhotoPreview = false
|
|
2672
|
+
}
|
|
2425
2673
|
}
|
|
2426
2674
|
}
|
|
2427
2675
|
}
|