react-native-photo-video-editor 0.2.1 → 0.3.0

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