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.
@@ -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 list.map { $0.id == id ? {
251
- var layer = $0; layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation; return layer
252
- }() : $0 } }
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
- layerBar.heightAnchor.constraint(equalToConstant: 76),
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
- /// Icon-tile + label node in the main tool rail (Studio Violet "Tool Node"). Mirrors Android's `ToolNode`.
364
- private struct ToolNode {
365
- let root: UIView
366
- let tile: UIView
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: UILabel
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
- @objc private func handleToolNodeTap(_ recognizer: UITapGestureRecognizer) {
378
- guard let view = recognizer.view else { return }
379
- toolNodeActions[ObjectIdentifier(view)]?()
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.root.accessibilityTraits = selected ? [.button, .selected] : .button
429
- node.tile.backgroundColor = selected ? accent.withAlphaComponent(0.18) : .clear
430
- node.icon.tintColor = selected ? accent : DesignTokens.textSecondary
431
- node.label.textColor = selected ? accent : DesignTokens.textSecondary
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(); scroll.backgroundColor = toolbarColor
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(); stack.axis = .horizontal; stack.spacing = DesignTokens.spaceXs
450
- // Width that shows about five tools at once, so the dock fills the screen evenly on a small
451
- // phone and does not leave large gaps on a large one.
452
- let itemWidth = Swift.min(Swift.max(UIScreen.main.bounds.width / 5, 64), 88)
453
- tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
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.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
552
+ node.button.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
456
553
  photoToolNodes[key] = node
457
- stack.addArrangedSubview(node.root)
458
- }
459
- // Built with the same node factory as the other seven so the dock has one visual language;
460
- // a zero-duration long press gives it the press-and-hold behaviour a tap gesture cannot.
461
- let compare = createToolNode(systemName: "rectangle.split.2x1", labelText: "Original") {}
462
- compare.root.accessibilityLabel = "Hold to compare with original"
463
- compare.root.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true
464
- let hold = UILongPressGestureRecognizer(target: self, action: #selector(compareHeld(_:)))
465
- hold.minimumPressDuration = 0
466
- compare.root.addGestureRecognizer(hold)
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.root)
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
- stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
569
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
474
570
  stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
475
- stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
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
- chipStack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
695
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: chipStack.trailingAnchor, constant: 8),
590
696
  chipStack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
591
- chipStack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
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 = .red
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
- let colors: [(String, UIColor)] = [("Red", .red), ("Yellow", .yellow), ("Green", .green), ("Blue", .systemBlue), ("White", .white), ("Black", .black)]
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
- let controls = UIStackView(); controls.axis = .horizontal; controls.spacing = 4
643
- colors.forEach { label, value in controls.addArrangedSubview(button(label, color: value) { [weak self] in self?.drawOverlay?.strokeColor = value }) }
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
- controls.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
790
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 8),
652
791
  controls.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
653
- controls.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
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 { activeLayerPropertyKey = "" }
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 simple = isText || currentSelectedLayer()?.type == .sticker
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: 22, weight: .regular))
1087
+ config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .regular))
910
1088
  config.imagePlacement = .top
911
- config.imagePadding = 6
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: 11, weight: .medium); return value
1092
+ var value = incoming; value.font = .systemFont(ofSize: 10, weight: .medium); return value
915
1093
  }
916
- config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4)
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: 68); width.priority = .defaultHigh; width.isActive = true
920
- control.heightAnchor.constraint(equalToConstant: 72).isActive = true
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(); scroll.showsHorizontalScrollIndicator = false
941
- let stack = UIStackView(); stack.axis = .horizontal
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: 8),
950
- stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
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
- stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
1134
+ scroll.contentLayoutGuide.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
953
1135
  stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
954
- scroll.heightAnchor.constraint(equalToConstant: 72),
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(); scroll.showsHorizontalScrollIndicator = false
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
- stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
1204
+ scroll.contentLayoutGuide.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 12),
1019
1205
  stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
1020
- stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
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 = DesignTokens.spaceXs
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, let imageView = photoImageView else { return }
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 list.map { $0.id == id ? {
1407
- var layer = $0; layer.x = x; layer.y = y; layer.scale = scale; layer.rotationDegrees = rotation; return layer
1408
- }() : $0 } }
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
- // The editor is intentionally single-video: omit the multi-clip strip and
1596
- // its Split/Copy/Delete/Reorder controls from the visible layout. Playback
1597
- // controls (transport/position) live inside `preview` itself, not in this stack.
1598
- let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
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: 76),
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
- guard let clip = session.clips.first,let track = session.asset(for:clip).tracks(withMediaType:.video).first else { return CGSize(width:1,height:1) }
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
- ("text", "Text", "textformat"), ("stickers", "Stickers", "face.smiling"),
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 dock = UIStackView()
1925
- dock.axis = .horizontal
1926
- dock.distribution = .fillEqually
1927
- dock.backgroundColor = DesignTokens.surfaceContainer
1928
- dock.layer.cornerRadius = 18
1929
- dock.clipsToBounds = true
1930
- tools.filter { features[$0.0] as? Bool != false }.forEach { key, label, symbol in
1931
- dock.addArrangedSubview(makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
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
- return dock
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 "crop": setVideoCropMode(true,session:session)
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 "crop":
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
- videoToolButtons["speed"]?.setTitle(formatSpeedLabel(session.state.speed), for: .normal)
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 so bitmap composition runs at most once per display frame.
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
- guard !photoPreviewRenderPending else { return }
2414
- photoPreviewRenderPending = true
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.photoPreviewRenderPending = false
2418
- guard self.viewIfLoaded?.window != nil,
2419
- let session = self.photoSession,
2420
- let imageView = self.photoImageView else { return }
2421
- autoreleasepool {
2422
- imageView.image = session.renderPreview(cropping:self.cropMode)
2423
- imageView.resetToFit()
2424
- if self.cropMode { self.cropOverlay?.restore(bounds:imageView.currentImageBounds(),state:session.state) }
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
  }