react-native-photo-video-editor 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +14 -4
- package/android/src/main/java/com/photovideoeditor/video/render/VideoBufferBudget.kt +10 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +27 -1
- package/docs/customization.md +1 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +118 -68
- package/ios/Editor/UI/CropPanel.swift +12 -5
- package/ios/PhotoVideoEditor.swift +3 -1
- package/ios/Video/Export/VideoExporter.swift +125 -80
- package/lib/typescript/src/types.d.ts +4 -0
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/types.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.2
|
|
6
|
+
|
|
7
|
+
- **Fix iOS video export crash**: Resolved `EXC_BREAKPOINT` / `_xpc_api_misuse` crash on iOS 26+ in `basicvideocompositor.output` by replacing `AVVideoCompositionCoreAnimationTool` with `AVVideoComposition(asset:applyingCIFiltersWithHandler:)` and Core Image compositing in `VideoExporter`.
|
|
8
|
+
- **Fix exported video overlay orientation on iOS**: Removed coordinate inversion that was causing stickers and text overlays to export upside down. Overlays now match preview orientation.
|
|
9
|
+
- **Video editor UI alignment with Android**:
|
|
10
|
+
- Simplified iOS video editor dock to only Text and Stickers tools, matching Android Kotlin.
|
|
11
|
+
- Removed video trim timeline bar from the main editing screen.
|
|
12
|
+
- Fixed layer property slider height, layout constraints, and toolbar spacing to prevent squashing and clipping across all device sizes.
|
|
13
|
+
- **Photo editor bottom toolbar enhancements**:
|
|
14
|
+
- Increased icon size (20pt SF Symbol in 36x36 tile) and font size (12.5pt medium) across the main bottom tool list.
|
|
15
|
+
- Increased button spacing and item width in the horizontal tool rail for improved legibility and touch ergonomics.
|
|
16
|
+
- **Crop panel adjustments**:
|
|
17
|
+
- Reduced font sizes of "Crop" header title (14pt semibold) and "Reset" button (12.5pt medium).
|
|
18
|
+
- Fixed text wrapping on the "Reset" button (previously breaking into `Re-` / `set`).
|
|
19
|
+
- Added insets and adjusted width on aspect ratio chips so "Original" stays on a single line.
|
|
20
|
+
- **Configurable completion button text**:
|
|
21
|
+
- Added `doneButtonText` (and alias `exportButtonText`) prop to `EditorOptions` and `EditorTheme`.
|
|
22
|
+
- Consumers can now pass custom strings (e.g. `'Done'`, `'Export'`, `'Save'`, `'Next'`) to customize the top-right header button on both iOS and Android (defaults to `'Done'`).
|
|
23
|
+
- **Standardized editor header title**:
|
|
24
|
+
- Standardized the default header title to `"Editor"` with 16pt font size on both iOS and Android.
|
|
25
|
+
|
|
26
|
+
## 0.3.1
|
|
27
|
+
|
|
28
|
+
- Reduce Android large-video memory pressure with a heap-aware preview sample-buffer target (up to 16 MiB), size-first buffering, and release of preview samples/decoders during export. Restore the paused preview after export cancellation or failure.
|
|
29
|
+
|
|
5
30
|
## 0.3.0
|
|
6
31
|
|
|
7
32
|
- Breaking API change: migrate `initialStickerId: 'brand'` to `initialStickerIds: ['brand']`.
|
package/README.md
CHANGED
|
@@ -78,6 +78,13 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
78
78
|
private val mediaType by lazy { intent.getStringExtra(EXTRA_TYPE).orEmpty() }
|
|
79
79
|
private val request by lazy { JSONObject(intent.getStringExtra(EXTRA_REQUEST).orEmpty()) }
|
|
80
80
|
private val theme by lazy { request.optJSONObject("theme") }
|
|
81
|
+
private val doneButtonText by lazy {
|
|
82
|
+
request.optString("doneButtonText").takeIf { it.isNotBlank() }
|
|
83
|
+
?: request.optString("exportButtonText").takeIf { it.isNotBlank() }
|
|
84
|
+
?: theme?.optString("doneButtonText")?.takeIf { it.isNotBlank() }
|
|
85
|
+
?: theme?.optString("exportButtonText")?.takeIf { it.isNotBlank() }
|
|
86
|
+
?: "Done"
|
|
87
|
+
}
|
|
81
88
|
|
|
82
89
|
private var photoSession: PhotoEditSession? = null
|
|
83
90
|
private var cropMode = false
|
|
@@ -2290,7 +2297,7 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
2290
2297
|
finishWithError("Nothing to export.", "E_INTERNAL")
|
|
2291
2298
|
return
|
|
2292
2299
|
}
|
|
2293
|
-
session.
|
|
2300
|
+
session.stopPreviewForExport()
|
|
2294
2301
|
videoExportProgressView.visibility = View.VISIBLE
|
|
2295
2302
|
val exporter = VideoExporter(applicationContext)
|
|
2296
2303
|
videoExporter = exporter
|
|
@@ -2321,11 +2328,13 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
2321
2328
|
runOnUiThread {
|
|
2322
2329
|
videoExporter = null
|
|
2323
2330
|
videoExportProgressView.visibility = View.GONE
|
|
2331
|
+
session.restorePreviewAfterExport()
|
|
2324
2332
|
Toast.makeText(this, error.message ?: "Unable to export the video.", Toast.LENGTH_LONG).show()
|
|
2325
2333
|
}
|
|
2326
2334
|
}
|
|
2327
2335
|
)
|
|
2328
2336
|
} catch (error: Exception) {
|
|
2337
|
+
session.restorePreviewAfterExport()
|
|
2329
2338
|
videoExporter = null
|
|
2330
2339
|
videoExportProgressView.visibility = View.GONE
|
|
2331
2340
|
Toast.makeText(this, error.message ?: "Unable to export the video.", Toast.LENGTH_LONG).show()
|
|
@@ -2334,6 +2343,7 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
2334
2343
|
|
|
2335
2344
|
private fun cancelVideoExport() {
|
|
2336
2345
|
videoExporter?.cancel()
|
|
2346
|
+
videoSession?.restorePreviewAfterExport()
|
|
2337
2347
|
videoExporter = null
|
|
2338
2348
|
videoExportProgressView.visibility = View.GONE
|
|
2339
2349
|
}
|
|
@@ -2544,8 +2554,8 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
2544
2554
|
)
|
|
2545
2555
|
|
|
2546
2556
|
addView(TextView(this@PhotoVideoEditorActivity).apply {
|
|
2547
|
-
text =
|
|
2548
|
-
textSize =
|
|
2557
|
+
text = "Editor"
|
|
2558
|
+
textSize = 16f
|
|
2549
2559
|
maxLines = 1
|
|
2550
2560
|
ellipsize = android.text.TextUtils.TruncateAt.END
|
|
2551
2561
|
gravity = Gravity.CENTER
|
|
@@ -2570,7 +2580,7 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
2570
2580
|
|
|
2571
2581
|
|
|
2572
2582
|
addView(
|
|
2573
|
-
editorPrimaryButton(this@PhotoVideoEditorActivity,
|
|
2583
|
+
editorPrimaryButton(this@PhotoVideoEditorActivity, doneButtonText, null, primaryColor, onPrimaryColor) {
|
|
2574
2584
|
if (mediaType == "photo") showPhotoExportConfiguration() else finishDone()
|
|
2575
2585
|
},
|
|
2576
2586
|
LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply { marginStart = dp(DesignTokens.spaceXs) }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
package com.photovideoeditor.video.render
|
|
2
|
+
|
|
3
|
+
/** Leave heap space for the host React Native app, decoded frames, and editor bitmaps. */
|
|
4
|
+
internal object VideoBufferBudget {
|
|
5
|
+
private const val MIB = 1024L * 1024L
|
|
6
|
+
|
|
7
|
+
// A target rather than a hard heap cap: Media3 can finish a sample after reaching it.
|
|
8
|
+
fun targetBytes(maxHeapBytes: Long): Int =
|
|
9
|
+
(maxHeapBytes / 16).coerceIn(2 * MIB, 16 * MIB).toInt()
|
|
10
|
+
}
|
|
@@ -6,6 +6,7 @@ import androidx.media3.common.MediaItem
|
|
|
6
6
|
import androidx.media3.common.PlaybackException
|
|
7
7
|
import androidx.media3.common.Player
|
|
8
8
|
import androidx.media3.exoplayer.ExoPlayer
|
|
9
|
+
import androidx.media3.exoplayer.DefaultLoadControl
|
|
9
10
|
import com.photovideoeditor.photo.render.PhotoLayerStack
|
|
10
11
|
import java.io.File
|
|
11
12
|
|
|
@@ -21,7 +22,17 @@ class VideoEditSession(context: Context, val sourceUri: String) {
|
|
|
21
22
|
private set
|
|
22
23
|
var sourceHeight = 0
|
|
23
24
|
private set
|
|
24
|
-
|
|
25
|
+
// Bound compressed samples independently of bitrate/duration. Time-first buffering
|
|
26
|
+
// can otherwise exhaust the host app's heap on high-bitrate camera footage.
|
|
27
|
+
val player: ExoPlayer = ExoPlayer.Builder(context)
|
|
28
|
+
.setLoadControl(DefaultLoadControl.Builder()
|
|
29
|
+
.setTargetBufferBytes(VideoBufferBudget.targetBytes(Runtime.getRuntime().maxMemory()))
|
|
30
|
+
.setBufferDurationsMs(2_000, 5_000, 500, 1_000)
|
|
31
|
+
.setPrioritizeTimeOverSizeThresholds(false)
|
|
32
|
+
.setBackBuffer(0, false)
|
|
33
|
+
.build())
|
|
34
|
+
.build()
|
|
35
|
+
private var previewStoppedForExport = false
|
|
25
36
|
|
|
26
37
|
/** Text/sticker/shape overlays. Reuses the photo layer model/stack — see `PhotoLayer.startMs`/`endMs` for timing. */
|
|
27
38
|
val layerStack = PhotoLayerStack()
|
|
@@ -133,6 +144,21 @@ class VideoEditSession(context: Context, val sourceUri: String) {
|
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
|
|
147
|
+
/** Pause alone retains samples and decoders; stop releases them while retaining the playlist/position. */
|
|
148
|
+
fun stopPreviewForExport() {
|
|
149
|
+
if (previewStoppedForExport) return
|
|
150
|
+
previewStoppedForExport = true
|
|
151
|
+
player.pause()
|
|
152
|
+
player.stop()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Export failure/cancellation returns to the same paused preview. */
|
|
156
|
+
fun restorePreviewAfterExport() {
|
|
157
|
+
if (!previewStoppedForExport) return
|
|
158
|
+
previewStoppedForExport = false
|
|
159
|
+
player.prepare()
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
fun release() {
|
|
137
163
|
onClipsReady = null
|
|
138
164
|
onPlaybackError = null
|
package/docs/customization.md
CHANGED
|
@@ -6,6 +6,7 @@ Pass `features` to control which tool entries appear. Photo-only normalization h
|
|
|
6
6
|
|
|
7
7
|
- `backgroundColor`, `toolbarColor`, `textColor`, `primaryColor` accept `#RRGGBB`/`#AARRGGBB` hex strings. Android parses them with `Color.parseColor`; iOS parses them itself. An unset or malformed value falls back to the original dark shell colors, so partial themes are safe.
|
|
8
8
|
- `statusBarStyle` (`'light' | 'dark'`) sets the status bar content color (Android `SYSTEM_UI_FLAG_LIGHT_STATUS_BAR`, iOS `preferredStatusBarStyle`).
|
|
9
|
+
- `doneButtonText` (or `exportButtonText`): custom label string for the top-right completion button (defaults to `'Done'`). Can be set on `theme` or directly on top-level `EditorOptions`.
|
|
9
10
|
|
|
10
11
|
Toolbar ordering/grouping beyond the fixed tool list is not yet implemented. The crop aspect-ratio preset list (Free, 1:1, 4:5, 3:4, 9:16, 16:9) and the filter/adjustment list are currently fixed natively and not yet configurable from the TypeScript API — a documented gap for a later milestone.
|
|
11
12
|
|
|
@@ -20,6 +20,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
20
20
|
private var initialStickerImages: [String: (URL, UIImage)] = [:]
|
|
21
21
|
private var initialStickerLoading = false
|
|
22
22
|
private let stickerAssets: [[String: Any]]
|
|
23
|
+
private let doneButtonText: String
|
|
23
24
|
var completion: ((PhotoVideoEditorOutcome) -> Void)?
|
|
24
25
|
|
|
25
26
|
private var photoSession: PhotoEditSession?
|
|
@@ -90,6 +91,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
90
91
|
private var videoNaturalSize: CGSize?
|
|
91
92
|
private var videoLayerOverlay: LayerOverlayView?
|
|
92
93
|
private var videoLayerToolBar: UIView?
|
|
94
|
+
private var videoToolBarWrapper: UIView?
|
|
93
95
|
private var videoLayerPropertySlider: UISlider?
|
|
94
96
|
private var selectedVideoLayerID: String?
|
|
95
97
|
private var activeVideoLayerPropertyKey = ""
|
|
@@ -122,7 +124,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
122
124
|
theme: [String: Any] = [:],
|
|
123
125
|
exportOptions: [String: Any] = [:],
|
|
124
126
|
stickerAssets: [[String: Any]] = [],
|
|
125
|
-
initialStickerIds: [String] = []
|
|
127
|
+
initialStickerIds: [String] = [],
|
|
128
|
+
doneButtonText: String? = nil
|
|
126
129
|
) {
|
|
127
130
|
self.uri = uri
|
|
128
131
|
self.mediaType = mediaType
|
|
@@ -131,6 +134,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
131
134
|
self.exportOptions = exportOptions
|
|
132
135
|
self.stickerAssets = stickerAssets
|
|
133
136
|
self.initialStickerIds = initialStickerIds
|
|
137
|
+
self.doneButtonText = doneButtonText ?? (theme["doneButtonText"] as? String) ?? (theme["exportButtonText"] as? String) ?? "Done"
|
|
134
138
|
super.init(nibName: nil, bundle: nil)
|
|
135
139
|
modalPresentationStyle = .fullScreen
|
|
136
140
|
}
|
|
@@ -462,7 +466,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
462
466
|
|
|
463
467
|
init(systemName: String, labelText: String, onTap: (() -> Void)? = nil) {
|
|
464
468
|
self.onTapAction = onTap
|
|
465
|
-
|
|
469
|
+
let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)
|
|
470
|
+
self.icon = UIImageView(image: UIImage(systemName: systemName, withConfiguration: config))
|
|
466
471
|
super.init(frame: .zero)
|
|
467
472
|
setupViews(labelText: labelText)
|
|
468
473
|
}
|
|
@@ -476,7 +481,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
476
481
|
|
|
477
482
|
tile.isUserInteractionEnabled = false
|
|
478
483
|
tile.backgroundColor = .clear
|
|
479
|
-
tile.layer.cornerRadius =
|
|
484
|
+
tile.layer.cornerRadius = 18
|
|
480
485
|
tile.clipsToBounds = true
|
|
481
486
|
|
|
482
487
|
icon.isUserInteractionEnabled = false
|
|
@@ -488,13 +493,13 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
488
493
|
NSLayoutConstraint.activate([
|
|
489
494
|
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
490
495
|
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
|
491
|
-
icon.widthAnchor.constraint(equalToConstant:
|
|
492
|
-
icon.heightAnchor.constraint(equalToConstant:
|
|
496
|
+
icon.widthAnchor.constraint(equalToConstant: 22),
|
|
497
|
+
icon.heightAnchor.constraint(equalToConstant: 22),
|
|
493
498
|
])
|
|
494
499
|
|
|
495
500
|
label.isUserInteractionEnabled = false
|
|
496
501
|
label.text = labelText
|
|
497
|
-
label.font = .systemFont(ofSize:
|
|
502
|
+
label.font = .systemFont(ofSize: 12.5, weight: .medium)
|
|
498
503
|
label.textColor = DesignTokens.textSecondary
|
|
499
504
|
label.textAlignment = .center
|
|
500
505
|
label.numberOfLines = 1
|
|
@@ -510,12 +515,12 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
510
515
|
addSubview(contentStack)
|
|
511
516
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
512
517
|
NSLayoutConstraint.activate([
|
|
513
|
-
tile.widthAnchor.constraint(equalToConstant:
|
|
514
|
-
tile.heightAnchor.constraint(equalToConstant:
|
|
518
|
+
tile.widthAnchor.constraint(equalToConstant: 36),
|
|
519
|
+
tile.heightAnchor.constraint(equalToConstant: 36),
|
|
515
520
|
contentStack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
516
521
|
contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
517
|
-
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant:
|
|
518
|
-
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -
|
|
522
|
+
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 1),
|
|
523
|
+
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -1),
|
|
519
524
|
])
|
|
520
525
|
|
|
521
526
|
if onTapAction != nil {
|
|
@@ -594,7 +599,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
594
599
|
let stack = UIStackView()
|
|
595
600
|
stack.axis = .horizontal
|
|
596
601
|
stack.spacing = 2
|
|
597
|
-
let itemWidth: CGFloat =
|
|
602
|
+
let itemWidth: CGFloat = 64
|
|
598
603
|
tools.filter { key, _, _ in
|
|
599
604
|
if key == "overlay" {
|
|
600
605
|
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
@@ -1157,7 +1162,13 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1157
1162
|
private func refreshContextSelection(_ buttons: [String: UIButton], active: String) {
|
|
1158
1163
|
buttons.forEach { key, control in
|
|
1159
1164
|
let selected = key == active
|
|
1160
|
-
|
|
1165
|
+
if key == "delete" {
|
|
1166
|
+
control.configuration?.baseForegroundColor = .systemRed
|
|
1167
|
+
} else if key == "done" {
|
|
1168
|
+
control.configuration?.baseForegroundColor = color("primaryColor") ?? DesignTokens.primaryContainer
|
|
1169
|
+
} else {
|
|
1170
|
+
control.configuration?.baseForegroundColor = selected ? (color("primaryColor") ?? DesignTokens.primaryContainer) : DesignTokens.outline
|
|
1171
|
+
}
|
|
1161
1172
|
control.backgroundColor = selected ? DesignTokens.surfaceContainerHigh : .clear
|
|
1162
1173
|
control.layer.cornerRadius = 12
|
|
1163
1174
|
control.accessibilityTraits = selected ? [.button, .selected] : .button
|
|
@@ -1830,6 +1841,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1830
1841
|
|
|
1831
1842
|
// Transparent holder so the safe-area inset lands on empty space rather than stretching the pill.
|
|
1832
1843
|
let toolbarWrapper = UIView()
|
|
1844
|
+
videoToolBarWrapper = toolbarWrapper
|
|
1833
1845
|
toolbarWrapper.addSubview(toolbar)
|
|
1834
1846
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
|
1835
1847
|
NSLayoutConstraint.activate([
|
|
@@ -1839,9 +1851,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1839
1851
|
toolbar.bottomAnchor.constraint(equalTo: toolbarWrapper.bottomAnchor, constant: -DesignTokens.spaceSm),
|
|
1840
1852
|
])
|
|
1841
1853
|
|
|
1842
|
-
|
|
1843
|
-
trim
|
|
1844
|
-
let root = UIStackView(arrangedSubviews: [header, previewWrapper,
|
|
1854
|
+
// Single-video editor: keep the internal range initialized for export,
|
|
1855
|
+
// but do not show the trim/clip timeline UI (matches Android).
|
|
1856
|
+
let root = UIStackView(arrangedSubviews: [header, previewWrapper, cropPanel, aspectBar, layerSlider, palette, layerBar, toolbarWrapper])
|
|
1845
1857
|
root.axis = .vertical
|
|
1846
1858
|
root.spacing = DesignTokens.spaceSm
|
|
1847
1859
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -1852,8 +1864,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1852
1864
|
root.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
1853
1865
|
root.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
1854
1866
|
header.heightAnchor.constraint(equalToConstant: 60),
|
|
1855
|
-
trim.heightAnchor.constraint(equalToConstant: 48),
|
|
1856
1867
|
aspectBar.heightAnchor.constraint(equalToConstant: 56),
|
|
1868
|
+
layerSlider.heightAnchor.constraint(equalToConstant: 64),
|
|
1869
|
+
palette.heightAnchor.constraint(equalToConstant: 48),
|
|
1857
1870
|
layerBar.heightAnchor.constraint(equalToConstant: 68),
|
|
1858
1871
|
toolbar.heightAnchor.constraint(equalToConstant: 52),
|
|
1859
1872
|
])
|
|
@@ -2060,8 +2073,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2060
2073
|
if visible { setVideoControlsVisible(true) }
|
|
2061
2074
|
videoLayerToolBar?.isHidden = !visible
|
|
2062
2075
|
videoToolBar?.isHidden = visible
|
|
2076
|
+
videoToolBarWrapper?.isHidden = visible
|
|
2063
2077
|
let isText = currentSelectedVideoLayer(session: session)?.type == .text
|
|
2064
|
-
videoLayerPropertyButtons["scale"]?.isHidden =
|
|
2078
|
+
videoLayerPropertyButtons["scale"]?.isHidden = true
|
|
2065
2079
|
["edit", "color", "fontSize"].forEach { videoLayerPropertyButtons[$0]?.isHidden = !isText }
|
|
2066
2080
|
if !isText && ["color", "fontSize"].contains(activeVideoLayerPropertyKey) { activeVideoLayerPropertyKey = "" }
|
|
2067
2081
|
videoLayerPropertySlider?.isHidden = !visible || !["scale", "rotation", "opacity", "fontSize"].contains(activeVideoLayerPropertyKey)
|
|
@@ -2092,6 +2106,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2092
2106
|
slider.minimumValue = Float(min)
|
|
2093
2107
|
slider.maximumValue = Float(max)
|
|
2094
2108
|
slider.value = Float(value)
|
|
2109
|
+
slider.setNeedsDisplay()
|
|
2095
2110
|
}
|
|
2096
2111
|
|
|
2097
2112
|
@objc private func videoLayerPropertyChanged(_ slider: UISlider) {
|
|
@@ -2118,21 +2133,78 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2118
2133
|
}
|
|
2119
2134
|
|
|
2120
2135
|
private func makeVideoLayerToolBar(session: VideoEditSession, textColor: UIColor, primaryColor: UIColor, toolbarColor: UIColor) -> UIView {
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2136
|
+
let bar = UIStackView()
|
|
2137
|
+
bar.axis = .horizontal
|
|
2138
|
+
bar.alignment = .fill
|
|
2139
|
+
bar.distribution = .fillEqually
|
|
2140
|
+
bar.backgroundColor = toolbarColor
|
|
2141
|
+
bar.isLayoutMarginsRelativeArrangement = true
|
|
2142
|
+
bar.layoutMargins = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4)
|
|
2143
|
+
|
|
2144
|
+
let actions: [(String, String, String)] = [
|
|
2145
|
+
("edit", "Edit", "pencil"),
|
|
2146
|
+
("color", "Color", "paintpalette"),
|
|
2147
|
+
("fontSize", "Size", "textformat.size"),
|
|
2148
|
+
("rotation", "Rotate", "arrow.clockwise"),
|
|
2149
|
+
("opacity", "Opacity", "circle.lefthalf.filled"),
|
|
2150
|
+
("duplicate", "Copy", "square.on.square"),
|
|
2151
|
+
("delete", "Delete", "trash"),
|
|
2152
|
+
("done", "Done", "checkmark"),
|
|
2153
|
+
]
|
|
2154
|
+
|
|
2155
|
+
for (key, label, symbol) in actions {
|
|
2156
|
+
let control = makeVideoToolButton(label, symbol: symbol) { [weak self] in
|
|
2157
|
+
guard let self, let layer = self.currentSelectedVideoLayer(session: session) else { return }
|
|
2158
|
+
switch key {
|
|
2159
|
+
case "edit":
|
|
2160
|
+
self.showVideoTextInputDialog(session: session, editingLayer: layer)
|
|
2161
|
+
case "done":
|
|
2162
|
+
self.selectVideoLayer(nil, session: session)
|
|
2163
|
+
case "delete":
|
|
2164
|
+
session.layerStack.commit { $0.filter { $0.id != layer.id } }
|
|
2165
|
+
self.selectVideoLayer(nil, session: session)
|
|
2166
|
+
self.onVideoLayerStackChanged()
|
|
2167
|
+
case "duplicate":
|
|
2168
|
+
let copy = layer.duplicated()
|
|
2169
|
+
session.layerStack.commit { $0 + [copy] }
|
|
2170
|
+
self.selectVideoLayer(copy.id, session: session)
|
|
2171
|
+
self.onVideoLayerStackChanged()
|
|
2172
|
+
default:
|
|
2173
|
+
self.activeVideoLayerPropertyKey = self.activeVideoLayerPropertyKey == key ? "" : key
|
|
2174
|
+
self.setVideoLayerToolBarVisible(true, session: session)
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if key == "done" {
|
|
2178
|
+
control.configuration?.baseForegroundColor = primaryColor
|
|
2179
|
+
} else if key == "delete" {
|
|
2180
|
+
control.configuration?.baseForegroundColor = .systemRed
|
|
2134
2181
|
}
|
|
2182
|
+
videoLayerPropertyButtons[key] = control
|
|
2183
|
+
bar.addArrangedSubview(control)
|
|
2135
2184
|
}
|
|
2185
|
+
return bar
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
private func makeVideoToolButton(_ label: String, symbol: String, action: @escaping () -> Void) -> UIButton {
|
|
2189
|
+
let control = UIButton(type: .system)
|
|
2190
|
+
var config = UIButton.Configuration.plain()
|
|
2191
|
+
config.title = label
|
|
2192
|
+
config.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .regular))
|
|
2193
|
+
config.imagePlacement = .top
|
|
2194
|
+
config.imagePadding = 3
|
|
2195
|
+
config.baseForegroundColor = DesignTokens.outline
|
|
2196
|
+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
2197
|
+
var value = incoming
|
|
2198
|
+
value.font = .systemFont(ofSize: 10, weight: .medium)
|
|
2199
|
+
return value
|
|
2200
|
+
}
|
|
2201
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 1, bottom: 6, trailing: 1)
|
|
2202
|
+
control.configuration = config
|
|
2203
|
+
control.accessibilityLabel = label
|
|
2204
|
+
control.heightAnchor.constraint(equalToConstant: 68).isActive = true
|
|
2205
|
+
control.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
|
2206
|
+
control.applyPressScale()
|
|
2207
|
+
return control
|
|
2136
2208
|
}
|
|
2137
2209
|
|
|
2138
2210
|
private func showVideoTextInputDialog(session: VideoEditSession, editingLayer: PhotoLayer? = nil) {
|
|
@@ -2164,49 +2236,27 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2164
2236
|
/// `toolButton` — that rail stacks a symbol above a label (72pt of chrome), far more than two
|
|
2165
2237
|
/// actions warrant. `textformat` renders as "Aa", which reads as typography where a lone "T" does not.
|
|
2166
2238
|
private func makeVideoToolBar(session: VideoEditSession, playerController: AVPlayerViewController) -> UIView {
|
|
2239
|
+
let dock = UIStackView()
|
|
2240
|
+
dock.axis = .horizontal
|
|
2241
|
+
dock.distribution = .fillEqually
|
|
2242
|
+
dock.alignment = .fill
|
|
2243
|
+
dock.backgroundColor = DesignTokens.surfaceContainer
|
|
2244
|
+
dock.layer.cornerRadius = 18
|
|
2245
|
+
dock.clipsToBounds = true
|
|
2246
|
+
|
|
2167
2247
|
let tools: [(String, String, String)] = [
|
|
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
2248
|
("text", "Text", "textformat"),
|
|
2173
2249
|
("stickers", "Stickers", "face.smiling"),
|
|
2174
|
-
("overlay", "Overlay", "plus.rectangle.on.rectangle"),
|
|
2175
|
-
("cover", "Cover", "photo"),
|
|
2176
2250
|
]
|
|
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
2251
|
tools.filter { key, _, _ in
|
|
2189
|
-
|
|
2190
|
-
return (features["overlay"] as? Bool ?? features["overlays"] as? Bool) != false
|
|
2191
|
-
}
|
|
2192
|
-
return features[key] as? Bool != false
|
|
2252
|
+
features[key] as? Bool != false
|
|
2193
2253
|
}.forEach { key, label, symbol in
|
|
2194
2254
|
let control = makeVideoDockAction(label: label, symbol: symbol) { [weak self, weak playerController] in
|
|
2195
2255
|
self?.onVideoToolTapped(key, session: session, playerView: playerController?.view)
|
|
2196
2256
|
}
|
|
2197
|
-
|
|
2198
|
-
stack.addArrangedSubview(control)
|
|
2257
|
+
dock.addArrangedSubview(control)
|
|
2199
2258
|
}
|
|
2200
|
-
|
|
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
|
|
2259
|
+
return dock
|
|
2210
2260
|
}
|
|
2211
2261
|
|
|
2212
2262
|
/// One dock action: symbol + label on a single row, purple pill while pressed.
|
|
@@ -2529,9 +2579,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2529
2579
|
close.clipsToBounds = true
|
|
2530
2580
|
|
|
2531
2581
|
let title = UILabel()
|
|
2532
|
-
title.text =
|
|
2582
|
+
title.text = "Editor"
|
|
2533
2583
|
title.textColor = textColor
|
|
2534
|
-
title.font = .systemFont(ofSize:
|
|
2584
|
+
title.font = .systemFont(ofSize: 16 , weight: .semibold)
|
|
2535
2585
|
title.adjustsFontSizeToFitWidth = true
|
|
2536
2586
|
title.minimumScaleFactor = 0.75
|
|
2537
2587
|
title.textAlignment = .center
|
|
@@ -2572,8 +2622,8 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2572
2622
|
}
|
|
2573
2623
|
|
|
2574
2624
|
let onPrimaryColor = color("onPrimaryColor") ?? DesignTokens.onPrimaryContainer
|
|
2575
|
-
let
|
|
2576
|
-
text:
|
|
2625
|
+
let done = EditorComponents.primaryButton(
|
|
2626
|
+
text: doneButtonText,
|
|
2577
2627
|
systemImage: nil,
|
|
2578
2628
|
fillColor: primaryColor,
|
|
2579
2629
|
textColor: onPrimaryColor
|
|
@@ -2581,7 +2631,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2581
2631
|
guard let self else { return }
|
|
2582
2632
|
if self.mediaType == "photo" { self.showPhotoExportConfiguration() } else { self.doneEditor() }
|
|
2583
2633
|
}
|
|
2584
|
-
header.addArrangedSubview(
|
|
2634
|
+
header.addArrangedSubview(done)
|
|
2585
2635
|
|
|
2586
2636
|
title.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
2587
2637
|
return header
|
|
@@ -25,11 +25,17 @@ final class CropPanel: UIStackView {
|
|
|
25
25
|
return button
|
|
26
26
|
}
|
|
27
27
|
let reset = UIButton(type:.system)
|
|
28
|
-
var resetStyle = UIButton.Configuration.plain(); resetStyle.title = "Reset"
|
|
29
|
-
resetStyle.
|
|
28
|
+
var resetStyle = UIButton.Configuration.plain(); resetStyle.title = "Reset"
|
|
29
|
+
resetStyle.image = UIImage(systemName:"arrow.counterclockwise", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .medium))
|
|
30
|
+
resetStyle.imagePadding = 5; resetStyle.baseForegroundColor = foreground
|
|
31
|
+
resetStyle.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
|
|
32
|
+
resetStyle.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
33
|
+
var output = incoming; output.font = .systemFont(ofSize: 12.5, weight: .medium); return output
|
|
34
|
+
}
|
|
30
35
|
reset.configuration = resetStyle; reset.addAction(UIAction { _ in onReset() },for:.touchUpInside)
|
|
31
|
-
reset.
|
|
32
|
-
|
|
36
|
+
reset.titleLabel?.numberOfLines = 1
|
|
37
|
+
reset.widthAnchor.constraint(equalToConstant:76).isActive = true
|
|
38
|
+
let title = UILabel(); title.text = "Crop"; title.font = .systemFont(ofSize:14,weight:.semibold); title.textColor = foreground; title.textAlignment = .center
|
|
33
39
|
let done = icon("checkmark",label:"Apply crop",action:onDone); done.tintColor = accent
|
|
34
40
|
let header = UIStackView(arrangedSubviews:[reset,title,icon("xmark",label:"Cancel crop",action:onCancel),done]); header.axis = .horizontal
|
|
35
41
|
header.heightAnchor.constraint(equalToConstant:48).isActive = true; addArrangedSubview(header)
|
|
@@ -48,6 +54,7 @@ final class CropPanel: UIStackView {
|
|
|
48
54
|
let control = UIButton(type:.system)
|
|
49
55
|
var config = UIButton.Configuration.plain(); config.title = name; config.imagePlacement = .top; config.imagePadding = 6
|
|
50
56
|
config.baseForegroundColor = foreground
|
|
57
|
+
config.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 2, bottom: 4, trailing: 2)
|
|
51
58
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { value in var output = value; output.font = .systemFont(ofSize:11); return output }
|
|
52
59
|
let aspect = name == "Original" ? max(0.01,originalRatio()) : ratio ?? 1.25
|
|
53
60
|
config.image = UIGraphicsImageRenderer(size:CGSize(width:30,height:30)).image { _ in
|
|
@@ -57,7 +64,7 @@ final class CropPanel: UIStackView {
|
|
|
57
64
|
}.withRenderingMode(.alwaysTemplate)
|
|
58
65
|
control.configuration = config; control.layer.cornerRadius = 12
|
|
59
66
|
control.accessibilityLabel = "\(name) crop ratio"
|
|
60
|
-
control.widthAnchor.constraint(equalToConstant:
|
|
67
|
+
control.widthAnchor.constraint(equalToConstant:66).isActive = true
|
|
61
68
|
control.addAction(UIAction { _ in onRatio(name,name == "Original" ? originalRatio() : ratio) },for:.touchUpInside)
|
|
62
69
|
control.applyPressScale(); rail.addArrangedSubview(control); presets[name] = control
|
|
63
70
|
}
|
|
@@ -24,6 +24,7 @@ public class PhotoVideoEditorSwift: NSObject {
|
|
|
24
24
|
guard !editorOpen else { reject("E_EDITOR_ALREADY_OPEN", "Another editor is already open.", nil); return }
|
|
25
25
|
guard let presenter = RCTPresentedViewController() else { reject("E_EDITOR_UNAVAILABLE", "The editor requires a foreground view controller.", nil); return }
|
|
26
26
|
editorOpen = true
|
|
27
|
+
let doneButtonText = payload["doneButtonText"] as? String ?? payload["exportButtonText"] as? String
|
|
27
28
|
let controller = PhotoVideoEditorViewController(
|
|
28
29
|
uri: uri,
|
|
29
30
|
mediaType: type,
|
|
@@ -31,7 +32,8 @@ public class PhotoVideoEditorSwift: NSObject {
|
|
|
31
32
|
theme: payload["theme"] as? [String: Any] ?? [:],
|
|
32
33
|
exportOptions: payload["export"] as? [String: Any] ?? [:],
|
|
33
34
|
stickerAssets: payload["stickerAssets"] as? [[String: Any]] ?? [],
|
|
34
|
-
initialStickerIds: payload["initialStickerIds"] as? [String] ?? []
|
|
35
|
+
initialStickerIds: payload["initialStickerIds"] as? [String] ?? [],
|
|
36
|
+
doneButtonText: doneButtonText
|
|
35
37
|
)
|
|
36
38
|
controller.completion = { [weak self, weak controller] outcome in
|
|
37
39
|
guard let self else { return }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
|
+
import CoreImage
|
|
2
3
|
import QuartzCore
|
|
3
4
|
import UIKit
|
|
4
5
|
|
|
@@ -56,13 +57,17 @@ final class VideoExporter {
|
|
|
56
57
|
|
|
57
58
|
var sourceVideoTrack: AVAssetTrack?
|
|
58
59
|
var cursor = CMTime.zero
|
|
60
|
+
var hasAudio = false
|
|
59
61
|
for clip in clips {
|
|
60
62
|
let clipAsset = assetProvider(clip)
|
|
61
63
|
guard let clipVideoTrack = clipAsset.tracks(withMediaType: .video).first else {
|
|
62
64
|
onError(VideoExportError(code: "E_SOURCE_UNREADABLE", message: "A clip's source video could not be decoded."))
|
|
63
65
|
return
|
|
64
66
|
}
|
|
65
|
-
if sourceVideoTrack == nil {
|
|
67
|
+
if sourceVideoTrack == nil {
|
|
68
|
+
sourceVideoTrack = clipVideoTrack
|
|
69
|
+
compositionVideoTrack.preferredTransform = clipVideoTrack.preferredTransform
|
|
70
|
+
}
|
|
66
71
|
let range = CMTimeRange(
|
|
67
72
|
start: CMTime(value: clip.trimStartMs, timescale: 1000),
|
|
68
73
|
end: CMTime(value: clip.effectiveTrimEndMs(), timescale: 1000)
|
|
@@ -74,10 +79,15 @@ final class VideoExporter {
|
|
|
74
79
|
return
|
|
75
80
|
}
|
|
76
81
|
if let compositionAudioTrack, let clipAudioTrack = clipAsset.tracks(withMediaType: .audio).first {
|
|
77
|
-
try? compositionAudioTrack.insertTimeRange(range, of: clipAudioTrack, at: cursor)
|
|
82
|
+
if (try? compositionAudioTrack.insertTimeRange(range, of: clipAudioTrack, at: cursor)) != nil {
|
|
83
|
+
hasAudio = true
|
|
84
|
+
}
|
|
78
85
|
}
|
|
79
86
|
cursor = CMTimeAdd(cursor, range.duration)
|
|
80
87
|
}
|
|
88
|
+
if !hasAudio, let compositionAudioTrack {
|
|
89
|
+
composition.removeTrack(compositionAudioTrack)
|
|
90
|
+
}
|
|
81
91
|
let timeRange = CMTimeRange(start: .zero, duration: cursor)
|
|
82
92
|
let durationMs = clips.reduce(Int64(0)) { $0 + $1.trimmedDurationMs() }
|
|
83
93
|
|
|
@@ -133,27 +143,27 @@ final class VideoExporter {
|
|
|
133
143
|
|
|
134
144
|
let cropPlan = VideoCropGeometry.plan(size:CGSize(width:renderWidth,height:renderHeight),state:state.crop,includeCrop:true)
|
|
135
145
|
transform = transform.concatenating(cropPlan.transform)
|
|
136
|
-
renderWidth = cropPlan.size.width
|
|
146
|
+
renderWidth = max(2, floor(cropPlan.size.width / 2) * 2)
|
|
147
|
+
renderHeight = max(2, floor(cropPlan.size.height / 2) * 2)
|
|
137
148
|
|
|
138
|
-
let
|
|
139
|
-
|
|
140
|
-
videoComposition
|
|
149
|
+
let renderSize = CGSize(width: renderWidth, height: renderHeight)
|
|
150
|
+
let visibleLayers = layers.filter(\.visible)
|
|
151
|
+
let videoComposition: AVVideoComposition
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
instruction.layerInstructions = [layerInstruction]
|
|
147
|
-
videoComposition.instructions = [instruction]
|
|
153
|
+
if visibleLayers.isEmpty {
|
|
154
|
+
let mutableComposition = AVMutableVideoComposition()
|
|
155
|
+
mutableComposition.renderSize = renderSize
|
|
156
|
+
mutableComposition.frameDuration = CMTime(value: 1, timescale: 30)
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
let instruction = AVMutableVideoCompositionInstruction()
|
|
159
|
+
instruction.timeRange = CMTimeRange(start: .zero, duration: timeRange.duration)
|
|
160
|
+
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
|
|
161
|
+
layerInstruction.setTransform(transform, at: .zero)
|
|
162
|
+
instruction.layerInstructions = [layerInstruction]
|
|
163
|
+
mutableComposition.instructions = [instruction]
|
|
164
|
+
videoComposition = mutableComposition
|
|
165
|
+
} else {
|
|
151
166
|
let longestEdge = max(renderSize.width, renderSize.height)
|
|
152
|
-
// Geometry is independent of this cap: each overlay surface below is a full-`renderSize`
|
|
153
|
-
// CALayer with `contentsGravity = .resizeAspect`, so a smaller (same-aspect) image is scaled
|
|
154
|
-
// back up to cover the whole frame. The cap only trades sharpness for peak memory. (Android's
|
|
155
|
-
// Media3 path has no such scale-to-fit — it composites overlay textures 1:1 in pixels — which
|
|
156
|
-
// is why `VideoExporter.kt` has to restore the same invariant explicitly via OverlaySettings.)
|
|
157
167
|
let overlayScale = min(1, 2560 / max(longestEdge, 1))
|
|
158
168
|
let overlayRenderSize = CGSize(
|
|
159
169
|
width: max(2, floor(renderSize.width * overlayScale)),
|
|
@@ -161,40 +171,100 @@ final class VideoExporter {
|
|
|
161
171
|
)
|
|
162
172
|
let renderer = PhotoEditSession.pixelRenderer(size: overlayRenderSize)
|
|
163
173
|
let transparentBase = renderer.image { _ in }
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
timingGroups.append((key, [layer]))
|
|
174
|
+
|
|
175
|
+
let overlayTransform = CGAffineTransform(
|
|
176
|
+
scaleX: renderSize.width / overlayRenderSize.width,
|
|
177
|
+
y: renderSize.height / overlayRenderSize.height
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
let lock = NSLock()
|
|
181
|
+
var overlayCache: [String: CIImage] = [:]
|
|
182
|
+
|
|
183
|
+
let mutableComposition = AVMutableVideoComposition(asset: composition) { request in
|
|
184
|
+
let timeMs = Int64(CMTimeGetSeconds(request.compositionTime) * 1000)
|
|
185
|
+
var source = request.sourceImage
|
|
186
|
+
|
|
187
|
+
if source.extent.origin != .zero {
|
|
188
|
+
source = source.transformed(by: CGAffineTransform(translationX: -source.extent.origin.x, y: -source.extent.origin.y))
|
|
180
189
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
let image = PhotoLayerRenderer.render(transparentBase, layers: group) { uri in
|
|
187
|
-
SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_video_layer_export").flatMap { UIImage(contentsOfFile: $0) }
|
|
190
|
+
|
|
191
|
+
if normalizedRotation != 0 {
|
|
192
|
+
let radians = CGFloat(normalizedRotation) * .pi / 180
|
|
193
|
+
source = source.transformed(by: CGAffineTransform(rotationAngle: radians))
|
|
194
|
+
source = source.transformed(by: CGAffineTransform(translationX: -source.extent.origin.x, y: -source.extent.origin.y))
|
|
188
195
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
|
|
197
|
+
if let targetRatio = state.aspectRatio, targetRatio > 0 {
|
|
198
|
+
let currentRatio = source.extent.width / source.extent.height
|
|
199
|
+
if currentRatio > targetRatio {
|
|
200
|
+
let croppedWidth = source.extent.height * targetRatio
|
|
201
|
+
let cropRect = CGRect(
|
|
202
|
+
x: source.extent.origin.x + (source.extent.width - croppedWidth) / 2,
|
|
203
|
+
y: source.extent.origin.y,
|
|
204
|
+
width: croppedWidth,
|
|
205
|
+
height: source.extent.height
|
|
206
|
+
)
|
|
207
|
+
source = source.cropped(to: cropRect).transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
|
|
208
|
+
} else if currentRatio < targetRatio {
|
|
209
|
+
let croppedHeight = source.extent.width / targetRatio
|
|
210
|
+
let cropRect = CGRect(
|
|
211
|
+
x: source.extent.origin.x,
|
|
212
|
+
y: source.extent.origin.y + (source.extent.height - croppedHeight) / 2,
|
|
213
|
+
width: source.extent.width,
|
|
214
|
+
height: croppedHeight
|
|
215
|
+
)
|
|
216
|
+
source = source.cropped(to: cropRect).transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if state.crop.rotationDegrees != 0 || state.crop.straightenDegrees != 0 || state.crop.cropLeft > 0 || state.crop.cropTop > 0 || state.crop.cropRight < 1 || state.crop.cropBottom < 1 {
|
|
221
|
+
let cropPlan = VideoCropGeometry.plan(size: CGSize(width: source.extent.width, height: source.extent.height), state: state.crop, includeCrop: true)
|
|
222
|
+
let cropRect = CGRect(
|
|
223
|
+
x: source.extent.origin.x + (source.extent.width - cropPlan.size.width) / 2,
|
|
224
|
+
y: source.extent.origin.y + (source.extent.height - cropPlan.size.height) / 2,
|
|
225
|
+
width: cropPlan.size.width,
|
|
226
|
+
height: cropPlan.size.height
|
|
227
|
+
)
|
|
228
|
+
source = source.cropped(to: cropRect).transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
|
|
195
229
|
}
|
|
230
|
+
|
|
231
|
+
let activeLayers = visibleLayers.filter { $0.isActive(atMs: timeMs, durationMs: durationMs) }
|
|
232
|
+
if !activeLayers.isEmpty {
|
|
233
|
+
let key = activeLayers.map { "\($0.id)" }.joined(separator: ",")
|
|
234
|
+
lock.lock()
|
|
235
|
+
let cached = overlayCache[key]
|
|
236
|
+
lock.unlock()
|
|
237
|
+
|
|
238
|
+
let overlayCI: CIImage?
|
|
239
|
+
if let cached {
|
|
240
|
+
overlayCI = cached
|
|
241
|
+
} else {
|
|
242
|
+
let image = PhotoLayerRenderer.render(transparentBase, layers: activeLayers) { uri in
|
|
243
|
+
SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_video_layer_export").flatMap { UIImage(contentsOfFile: $0) }
|
|
244
|
+
}
|
|
245
|
+
if let cgImage = image.cgImage {
|
|
246
|
+
let ci = CIImage(cgImage: cgImage).transformed(by: overlayTransform)
|
|
247
|
+
lock.lock()
|
|
248
|
+
overlayCache[key] = ci
|
|
249
|
+
lock.unlock()
|
|
250
|
+
overlayCI = ci
|
|
251
|
+
} else {
|
|
252
|
+
overlayCI = nil
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if let overlayCI {
|
|
257
|
+
source = overlayCI.composited(over: source)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let output = source.cropped(to: CGRect(origin: .zero, size: renderSize))
|
|
262
|
+
request.finish(with: output, context: nil)
|
|
196
263
|
}
|
|
197
|
-
|
|
264
|
+
|
|
265
|
+
mutableComposition.renderSize = renderSize
|
|
266
|
+
mutableComposition.frameDuration = CMTime(value: 1, timescale: 30)
|
|
267
|
+
videoComposition = mutableComposition
|
|
198
268
|
}
|
|
199
269
|
|
|
200
270
|
let outputDir = FileManager.default.temporaryDirectory.appendingPathComponent("photovideoeditor", isDirectory: true)
|
|
@@ -203,6 +273,7 @@ final class VideoExporter {
|
|
|
203
273
|
outputURL = output
|
|
204
274
|
|
|
205
275
|
guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
|
276
|
+
cleanup()
|
|
206
277
|
onError(VideoExportError(code: "E_INTERNAL", message: "Unable to create the export session."))
|
|
207
278
|
return
|
|
208
279
|
}
|
|
@@ -219,7 +290,7 @@ final class VideoExporter {
|
|
|
219
290
|
|
|
220
291
|
session.exportAsynchronously { [weak self] in
|
|
221
292
|
guard let self else { return }
|
|
222
|
-
self.
|
|
293
|
+
self.cleanup()
|
|
223
294
|
switch session.status {
|
|
224
295
|
case .completed:
|
|
225
296
|
self.outputURL = nil
|
|
@@ -245,40 +316,14 @@ final class VideoExporter {
|
|
|
245
316
|
}
|
|
246
317
|
}
|
|
247
318
|
|
|
248
|
-
private func
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if start == 0 && end >= durationMs {
|
|
252
|
-
surface.opacity = 1
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
let animation = CAKeyframeAnimation(keyPath: "opacity")
|
|
256
|
-
var values: [NSNumber] = []
|
|
257
|
-
var times: [NSNumber] = []
|
|
258
|
-
if start > 0 {
|
|
259
|
-
values.append(0); times.append(0)
|
|
260
|
-
values.append(1); times.append(NSNumber(value: Double(start) / Double(max(durationMs, 1))))
|
|
261
|
-
} else {
|
|
262
|
-
values.append(1); times.append(0)
|
|
263
|
-
}
|
|
264
|
-
if end < durationMs {
|
|
265
|
-
values.append(0); times.append(NSNumber(value: Double(end) / Double(max(durationMs, 1))))
|
|
266
|
-
} else {
|
|
267
|
-
values.append(1); times.append(1)
|
|
268
|
-
}
|
|
269
|
-
animation.values = values
|
|
270
|
-
animation.keyTimes = times
|
|
271
|
-
animation.calculationMode = .discrete
|
|
272
|
-
animation.beginTime = AVCoreAnimationBeginTimeAtZero
|
|
273
|
-
animation.duration = durationSeconds
|
|
274
|
-
animation.fillMode = .both
|
|
275
|
-
animation.isRemovedOnCompletion = false
|
|
276
|
-
surface.add(animation, forKey: "overlayVisibility")
|
|
319
|
+
private func cleanup() {
|
|
320
|
+
progressTimer?.invalidate()
|
|
321
|
+
progressTimer = nil
|
|
277
322
|
}
|
|
278
323
|
|
|
279
324
|
func cancel() {
|
|
280
325
|
exportSession?.cancelExport()
|
|
281
|
-
|
|
326
|
+
cleanup()
|
|
282
327
|
if let outputURL { try? FileManager.default.removeItem(at: outputURL) }
|
|
283
328
|
}
|
|
284
329
|
}
|
|
@@ -33,6 +33,8 @@ export interface EditorTheme {
|
|
|
33
33
|
/** Toolbar color. */ toolbarColor?: string;
|
|
34
34
|
/** Foreground text color. */ textColor?: string;
|
|
35
35
|
/** Status bar content style. */ statusBarStyle?: 'light' | 'dark';
|
|
36
|
+
/** Custom label for the top-right completion/export button (defaults to "Done"). */ doneButtonText?: string;
|
|
37
|
+
/** Alias for doneButtonText. */ exportButtonText?: string;
|
|
36
38
|
}
|
|
37
39
|
/** Export preferences. The milestone-one placeholder does not export media. */
|
|
38
40
|
export interface ExportOptions {
|
|
@@ -61,6 +63,8 @@ export interface EditorOptions {
|
|
|
61
63
|
/** Enabled tools. */ features?: EditorFeatures;
|
|
62
64
|
/** Native UI theme. */ theme?: EditorTheme;
|
|
63
65
|
/** Export preferences. */ export?: ExportOptions;
|
|
66
|
+
/** Custom label for the top-right completion/export button (defaults to "Done"). */ doneButtonText?: string;
|
|
67
|
+
/** Alias for doneButtonText. */ exportButtonText?: string;
|
|
64
68
|
/** Requests saving to the gallery. Currently ignored by the placeholder. */ saveToGallery?: boolean;
|
|
65
69
|
/** Ordered, unique IDs in stickerAssets. The first starts centered; the sticker’s top-right control cycles through the rest. */ initialStickerIds?: string[];
|
|
66
70
|
/** Consumer-provided stickers offered by the Stickers tool, in addition to the small built-in set and end-user uploads. */ stickerAssets?: StickerAsset[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/types.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAC1C,uCAAuC;AACvC,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;AACnE,wCAAwC;AACxC,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AACxD,wCAAwC;AACxC,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,KAAK,CAAC;AAC9C,sDAAsD;AACtD,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,WAAW,GACX,SAAS,GACT,WAAW,GACX,WAAW,GACX,WAAW,GACX,QAAQ,CAAC;AAEb,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,8BAA8B,CAAC,GAAG,EAAE,MAAM,CAAC;IAC3C,kBAAkB,CAAC,IAAI,EAAE,SAAS,CAAC;IACnC,+BAA+B,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClD,iCAAiC,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrD;AACD,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,wBAAwB,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxC,wBAAwB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1C,8BAA8B,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC9C,uBAAuB,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC1C,6BAA6B,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC7C,yEAAyE,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC7F,0EAA0E,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC1F,oDAAoD,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACzE;AACD,+CAA+C;AAC/C,MAAM,WAAW,WAAW;IAC1B,+BAA+B,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtD,+BAA+B,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzD,qBAAqB,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC5C,6BAA6B,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IACjD,gCAAgC,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/types.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAC1C,uCAAuC;AACvC,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;AACnE,wCAAwC;AACxC,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AACxD,wCAAwC;AACxC,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,KAAK,CAAC;AAC9C,sDAAsD;AACtD,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,WAAW,GACX,SAAS,GACT,WAAW,GACX,WAAW,GACX,WAAW,GACX,QAAQ,CAAC;AAEb,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,8BAA8B,CAAC,GAAG,EAAE,MAAM,CAAC;IAC3C,kBAAkB,CAAC,IAAI,EAAE,SAAS,CAAC;IACnC,+BAA+B,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClD,iCAAiC,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrD;AACD,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,wBAAwB,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxC,wBAAwB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1C,8BAA8B,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC9C,uBAAuB,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC1C,6BAA6B,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC7C,yEAAyE,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC7F,0EAA0E,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAC1F,oDAAoD,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACzE;AACD,+CAA+C;AAC/C,MAAM,WAAW,WAAW;IAC1B,+BAA+B,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtD,+BAA+B,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzD,qBAAqB,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC5C,6BAA6B,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IACjD,gCAAgC,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACnE,oFAAoF,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAC7G,gCAAgC,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC5D;AACD,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,sBAAsB,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAC/C,+CAA+C,CAAC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChF,+CAA+C,CAAC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChF,wDAAwD,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3E,yDAAyD,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC7E,0DAA0D,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IACjF,+DAA+D,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnF,0DAA0D,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;CACvF;AACD,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,mEAAmE,CAAC,MAAM,EAAE,MAAM,CAAC;IACnF,wDAAwD,CAAC,GAAG,EAAE,MAAM,CAAC;CACtE;AACD,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,wEAAwE,CAAC,EAAE,EAAE,MAAM,CAAC;IACpF,8GAA8G,CAAC,GAAG,EAAE,MAAM,CAAC;CAC5H;AACD,8CAA8C;AAC9C,MAAM,WAAW,aAAa;IAC5B,4BAA4B,CAAC,MAAM,EAAE,YAAY,CAAC;IAClD,qBAAqB,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAChD,uBAAuB,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC5C,0BAA0B,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAClD,oFAAoF,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAC7G,gCAAgC,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC3D,4EAA4E,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACrG,gIAAgI,CAAC,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9J,2HAA2H,CAAC,aAAa,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3J,6DAA6D,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;CACnF;AACD,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC;IACvE,MAAM,EAAE,YAAY,GAAG;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AACD,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC;IACvE,MAAM,EAAE,YAAY,GAAG;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AACD,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,aAAa,GACb,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,gBAAgB,GAChB,iBAAiB,GACjB,iBAAiB,GACjB,cAAc,GACd,OAAO,CAAC;AACZ,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,eAAe,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AACD,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;AAC/D,8CAA8C;AAC9C,MAAM,WAAW,YAAY;IAC3B,wBAAwB,CAAC,GAAG,EAAE,MAAM,CAAC;IACrC,yBAAyB,CAAC,IAAI,EAAE,SAAS,CAAC;IAC1C,4BAA4B,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC/C,kCAAkC,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAClD,mCAAmC,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACpD,iDAAiD,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IACpE,qCAAqC,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IACxD,4CAA4C,CAAC,SAAS,EAAE,OAAO,CAAC;CACjE;AACD,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,6BAA6B,CAAC,KAAK,EAAE,MAAM,CAAC;IAC5C,sCAAsC,CAAC,QAAQ,EAAE,MAAM,CAAC;IACxD,+BAA+B,CAAC,MAAM,EAAE,YAAY,CAAC;CACtD;AACD,oCAAoC;AACpC,MAAM,WAAW,kBAAkB;IACjC,uDAAuD,CAAC,MAAM,IAAI,IAAI,CAAC;CACxE"}
|
package/package.json
CHANGED
package/src/types.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface EditorTheme {
|
|
|
41
41
|
/** Toolbar color. */ toolbarColor?: string;
|
|
42
42
|
/** Foreground text color. */ textColor?: string;
|
|
43
43
|
/** Status bar content style. */ statusBarStyle?: 'light' | 'dark';
|
|
44
|
+
/** Custom label for the top-right completion/export button (defaults to "Done"). */ doneButtonText?: string;
|
|
45
|
+
/** Alias for doneButtonText. */ exportButtonText?: string;
|
|
44
46
|
}
|
|
45
47
|
/** Export preferences. The milestone-one placeholder does not export media. */
|
|
46
48
|
export interface ExportOptions {
|
|
@@ -69,6 +71,8 @@ export interface EditorOptions {
|
|
|
69
71
|
/** Enabled tools. */ features?: EditorFeatures;
|
|
70
72
|
/** Native UI theme. */ theme?: EditorTheme;
|
|
71
73
|
/** Export preferences. */ export?: ExportOptions;
|
|
74
|
+
/** Custom label for the top-right completion/export button (defaults to "Done"). */ doneButtonText?: string;
|
|
75
|
+
/** Alias for doneButtonText. */ exportButtonText?: string;
|
|
72
76
|
/** Requests saving to the gallery. Currently ignored by the placeholder. */ saveToGallery?: boolean;
|
|
73
77
|
/** Ordered, unique IDs in stickerAssets. The first starts centered; the sticker’s top-right control cycles through the rest. */ initialStickerIds?: string[];
|
|
74
78
|
/** Consumer-provided stickers offered by the Stickers tool, in addition to the small built-in set and end-user uploads. */ stickerAssets?: StickerAsset[];
|