react-native-photo-video-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +101 -0
- package/LICENSE +20 -0
- package/PhotoVideoEditor.podspec +23 -0
- package/README.md +53 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
- package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
- package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
- package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
- package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
- package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
- package/android/src/main/res/drawable/ic_add.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
- package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
- package/android/src/main/res/drawable/ic_check.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
- package/android/src/main/res/drawable/ic_close.xml +9 -0
- package/android/src/main/res/drawable/ic_compare.xml +9 -0
- package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
- package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +9 -0
- package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
- package/android/src/main/res/drawable/ic_face.xml +9 -0
- package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
- package/android/src/main/res/drawable/ic_flare.xml +9 -0
- package/android/src/main/res/drawable/ic_format_size.xml +9 -0
- package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
- package/android/src/main/res/drawable/ic_layers.xml +9 -0
- package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
- package/android/src/main/res/drawable/ic_lock.xml +9 -0
- package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
- package/android/src/main/res/drawable/ic_movie.xml +9 -0
- package/android/src/main/res/drawable/ic_opacity.xml +9 -0
- package/android/src/main/res/drawable/ic_palette.xml +9 -0
- package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
- package/android/src/main/res/drawable/ic_redo.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
- package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +9 -0
- package/android/src/main/res/drawable/ic_speed.xml +9 -0
- package/android/src/main/res/drawable/ic_straighten.xml +9 -0
- package/android/src/main/res/drawable/ic_title.xml +9 -0
- package/android/src/main/res/drawable/ic_tune.xml +9 -0
- package/android/src/main/res/drawable/ic_undo.xml +9 -0
- package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
- package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/docs/api.md +9 -0
- package/docs/architecture.md +9 -0
- package/docs/customization.md +18 -0
- package/docs/development.md +21 -0
- package/docs/formats.md +27 -0
- package/docs/performance.md +3 -0
- package/docs/permissions.md +3 -0
- package/docs/photo-editor.md +41 -0
- package/docs/roadmap.md +12 -0
- package/docs/technology-decisions.md +17 -0
- package/docs/video-editor.md +76 -0
- package/ios/Editor/DesignTokens.swift +75 -0
- package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
- package/ios/Editor/README.md +5 -0
- package/ios/Editor/UI/EditorComponents.swift +138 -0
- package/ios/Files/SourceResolver.swift +13 -0
- package/ios/Models/EditorRequest.swift +3 -0
- package/ios/Photo/Export/PhotoExporter.swift +144 -0
- package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
- package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
- package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
- package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
- package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
- package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
- package/ios/Photo/UI/CropOverlayView.swift +161 -0
- package/ios/Photo/UI/LayerOverlayView.swift +176 -0
- package/ios/Photo/UI/ZoomableImageView.swift +79 -0
- package/ios/PhotoVideoEditor.mm +20 -0
- package/ios/PhotoVideoEditor.swift +70 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
- package/ios/Stickers/OpenMojiCatalog.swift +188 -0
- package/ios/Stickers/RemoteImageLoader.swift +127 -0
- package/ios/Validation/EditorRequestValidator.swift +9 -0
- package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
- package/ios/Video/Export/VideoExporter.swift +275 -0
- package/ios/Video/Render/VideoClip.swift +21 -0
- package/ios/Video/Render/VideoEditSession.swift +99 -0
- package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
- package/ios/Video/Render/VideoTransformState.swift +23 -0
- package/ios/Video/UI/TrimRangeView.swift +91 -0
- package/lib/module/NativePhotoVideoEditor.js +5 -0
- package/lib/module/NativePhotoVideoEditor.js.map +1 -0
- package/lib/module/errors.js +29 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +27 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +91 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/project.js +4 -0
- package/lib/module/project.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +11 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +5 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/project.d.ts +24 -0
- package/lib/typescript/src/project.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +108 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/NativePhotoVideoEditor.ts +12 -0
- package/src/errors.ts +78 -0
- package/src/events.ts +43 -0
- package/src/index.ts +123 -0
- package/src/project.ts +17 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Planned iOS editor
|
|
2
|
+
|
|
3
|
+
Milestone one contains no editing engine. A future full-screen editor will use `AVPlayer` for preview, AVFoundation composition/export APIs for timelines and final output, and Core Image/Core Graphics for photo filtering and composition. Metal will be introduced only when measured real-time rendering requirements justify it.
|
|
4
|
+
|
|
5
|
+
Temporary/output URL ownership and cleanup will be explicit. Photos framework integration will occur only when gallery saving is requested; the consuming app—not this library—must provide the relevant usage descriptions.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Shared "Studio Violet" UI primitives (DESIGN.md), reused across the photo/video editor shell
|
|
4
|
+
/// instead of hand-rolling styling per screen. Mirrors `EditorComponents.kt` on Android.
|
|
5
|
+
|
|
6
|
+
/// A `UITapGestureRecognizer` that takes a closure instead of a target/action pair.
|
|
7
|
+
final class ClosureTapGestureRecognizer: UITapGestureRecognizer {
|
|
8
|
+
private let action: () -> Void
|
|
9
|
+
|
|
10
|
+
init(action: @escaping () -> Void) {
|
|
11
|
+
self.action = action
|
|
12
|
+
super.init(target: nil, action: nil)
|
|
13
|
+
addTarget(self, action: #selector(handle))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@objc private func handle() { action() }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Tactile press feedback (scale 1.0 -> 0.97) matching DESIGN.md's "Tactile Instrument Feel".
|
|
20
|
+
/// Only observes touch-down/up via a zero-duration long-press recognizer with
|
|
21
|
+
/// `cancelsTouchesInView = false`, so it never interferes with an existing tap recognizer
|
|
22
|
+
/// (e.g. the tool rail's `handleToolNodeTap`) already attached to the same view.
|
|
23
|
+
private final class EditorPressFeedback: NSObject {
|
|
24
|
+
private let pressedScale: CGFloat
|
|
25
|
+
private weak var target: UIView?
|
|
26
|
+
|
|
27
|
+
init(target: UIView, pressedScale: CGFloat) {
|
|
28
|
+
self.target = target
|
|
29
|
+
self.pressedScale = pressedScale
|
|
30
|
+
super.init()
|
|
31
|
+
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handle(_:)))
|
|
32
|
+
recognizer.minimumPressDuration = 0
|
|
33
|
+
recognizer.cancelsTouchesInView = false
|
|
34
|
+
target.addGestureRecognizer(recognizer)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@objc private func handle(_ recognizer: UILongPressGestureRecognizer) {
|
|
38
|
+
guard let target else { return }
|
|
39
|
+
switch recognizer.state {
|
|
40
|
+
case .began:
|
|
41
|
+
UIView.animate(withDuration: 0.08) { target.transform = CGAffineTransform(scaleX: self.pressedScale, y: self.pressedScale) }
|
|
42
|
+
case .ended, .cancelled, .failed:
|
|
43
|
+
UIView.animate(withDuration: 0.12) { target.transform = .identity }
|
|
44
|
+
default:
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private var pressFeedbackAssociationKey: UInt8 = 0
|
|
51
|
+
|
|
52
|
+
extension UIView {
|
|
53
|
+
/// Attaches (once) the shared press-scale feedback to this view.
|
|
54
|
+
func applyPressScale(_ scale: CGFloat = 0.97) {
|
|
55
|
+
guard objc_getAssociatedObject(self, &pressFeedbackAssociationKey) == nil else { return }
|
|
56
|
+
let feedback = EditorPressFeedback(target: self, pressedScale: scale)
|
|
57
|
+
objc_setAssociatedObject(self, &pressFeedbackAssociationKey, feedback, .OBJC_ASSOCIATION_RETAIN)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
enum EditorComponents {
|
|
62
|
+
/// Elevated rounded container (DESIGN.md "Elevated Panels & Tiles"), used for cards/tiles/sheets.
|
|
63
|
+
static func card(
|
|
64
|
+
radius: CGFloat = DesignTokens.radiusLg,
|
|
65
|
+
backgroundColor: UIColor = DesignTokens.elevatedPanel,
|
|
66
|
+
borderColor: UIColor? = DesignTokens.interactiveNeutral
|
|
67
|
+
) -> UIView {
|
|
68
|
+
let view = UIView()
|
|
69
|
+
view.backgroundColor = backgroundColor
|
|
70
|
+
view.layer.cornerRadius = radius
|
|
71
|
+
if let borderColor {
|
|
72
|
+
view.layer.borderColor = borderColor.cgColor
|
|
73
|
+
view.layer.borderWidth = 1
|
|
74
|
+
}
|
|
75
|
+
return view
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Pill chip for quick actions / filter tags (DESIGN.md "Chips & Filter Presets").
|
|
79
|
+
static func chip(text: String, selected: Bool, onTap: @escaping () -> Void) -> UIView {
|
|
80
|
+
let label = UILabel()
|
|
81
|
+
label.text = text
|
|
82
|
+
label.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
83
|
+
label.textColor = selected ? DesignTokens.textPrimary : DesignTokens.textSecondary
|
|
84
|
+
label.textAlignment = .center
|
|
85
|
+
|
|
86
|
+
let container = UIView()
|
|
87
|
+
container.backgroundColor = selected ? DesignTokens.accentViolet : DesignTokens.elevatedPanel
|
|
88
|
+
container.layer.cornerRadius = 16
|
|
89
|
+
if !selected {
|
|
90
|
+
container.layer.borderColor = DesignTokens.interactiveNeutral.cgColor
|
|
91
|
+
container.layer.borderWidth = 1
|
|
92
|
+
}
|
|
93
|
+
container.addSubview(label)
|
|
94
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
95
|
+
NSLayoutConstraint.activate([
|
|
96
|
+
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: DesignTokens.spaceLg),
|
|
97
|
+
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -DesignTokens.spaceLg),
|
|
98
|
+
label.topAnchor.constraint(equalTo: container.topAnchor, constant: DesignTokens.spaceXs),
|
|
99
|
+
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -DesignTokens.spaceXs),
|
|
100
|
+
container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
|
|
101
|
+
])
|
|
102
|
+
container.isUserInteractionEnabled = true
|
|
103
|
+
container.addGestureRecognizer(ClosureTapGestureRecognizer(action: onTap))
|
|
104
|
+
container.applyPressScale()
|
|
105
|
+
return container
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Primary Export CTA (DESIGN.md "Primary Export Button"): solid violet fill, 44pt min height,
|
|
109
|
+
/// 12px radius, subtle glow shadow, 0.97 press scale.
|
|
110
|
+
static func primaryButton(
|
|
111
|
+
text: String,
|
|
112
|
+
systemImage: String? = nil,
|
|
113
|
+
fillColor: UIColor = DesignTokens.accentViolet,
|
|
114
|
+
textColor: UIColor = DesignTokens.textPrimary,
|
|
115
|
+
onTap: @escaping () -> Void
|
|
116
|
+
) -> UIButton {
|
|
117
|
+
let button = UIButton(type: .system)
|
|
118
|
+
button.setTitle(text, for: .normal)
|
|
119
|
+
button.setTitleColor(textColor, for: .normal)
|
|
120
|
+
if let systemImage { button.setImage(UIImage(systemName: systemImage), for: .normal) }
|
|
121
|
+
button.tintColor = textColor
|
|
122
|
+
button.backgroundColor = fillColor
|
|
123
|
+
button.layer.cornerRadius = DesignTokens.radiusLg
|
|
124
|
+
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
|
|
125
|
+
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: DesignTokens.spaceLg, bottom: 0, right: DesignTokens.spaceLg)
|
|
126
|
+
if #available(iOS 15.0, *) {
|
|
127
|
+
button.configuration = nil // avoid UIButtonConfiguration overriding our manual styling on iOS 15+
|
|
128
|
+
}
|
|
129
|
+
button.heightAnchor.constraint(equalToConstant: DesignTokens.touchTargetMin).isActive = true
|
|
130
|
+
button.layer.shadowColor = fillColor.cgColor
|
|
131
|
+
button.layer.shadowOpacity = 0.35
|
|
132
|
+
button.layer.shadowRadius = 12
|
|
133
|
+
button.layer.shadowOffset = CGSize(width: 0, height: 4)
|
|
134
|
+
button.addAction(UIAction { _ in onTap() }, for: .touchUpInside)
|
|
135
|
+
button.applyPressScale()
|
|
136
|
+
return button
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Resolves an editor source uri (file:// or a bare path) to a local
|
|
4
|
+
/// filesystem path that ImageIO can read directly. Photos-library identifiers
|
|
5
|
+
/// (ph://) and remote https:// sources are not yet supported.
|
|
6
|
+
enum SourceResolver {
|
|
7
|
+
static func resolvePath(sourceUri: String, tempPrefix: String) -> String? {
|
|
8
|
+
guard let url = URL(string: sourceUri) else { return nil }
|
|
9
|
+
if url.isFileURL { return url.path }
|
|
10
|
+
if url.scheme == nil { return sourceUri }
|
|
11
|
+
return nil
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import ImageIO
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
struct PhotoExportResult {
|
|
5
|
+
let uri: String
|
|
6
|
+
let width: Int
|
|
7
|
+
let height: Int
|
|
8
|
+
let fileSize: Int
|
|
9
|
+
let mimeType: String
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct PhotoExportError: Error {
|
|
13
|
+
let code: String
|
|
14
|
+
let message: String
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Applies a `PhotoTransformState` to the full-resolution source photo and
|
|
18
|
+
/// writes the result to a new file under the app's temporary directory. The
|
|
19
|
+
/// source file is never modified. WebP export is not implemented on iOS
|
|
20
|
+
/// (no first-party encoder); JPEG and PNG are supported.
|
|
21
|
+
enum PhotoExporter {
|
|
22
|
+
private static let defaultMaxDimension: CGFloat = 4096
|
|
23
|
+
|
|
24
|
+
static func export(
|
|
25
|
+
sourceUri: String,
|
|
26
|
+
transform: PhotoTransformState,
|
|
27
|
+
adjustments: PhotoAdjustments,
|
|
28
|
+
layers: [PhotoLayer],
|
|
29
|
+
exportOptions: [String: Any]?
|
|
30
|
+
) throws -> PhotoExportResult {
|
|
31
|
+
guard let path = SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_export") else {
|
|
32
|
+
throw PhotoExportError(code: "E_SOURCE_NOT_FOUND", message: "The selected photo could not be found.")
|
|
33
|
+
}
|
|
34
|
+
let url = URL(fileURLWithPath: path)
|
|
35
|
+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
|
36
|
+
throw PhotoExportError(code: "E_SOURCE_UNREADABLE", message: "The selected photo could not be decoded.")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let maxWidth = (exportOptions?["maxWidth"] as? NSNumber)?.doubleValue.map { CGFloat($0) }
|
|
40
|
+
let maxHeight = (exportOptions?["maxHeight"] as? NSNumber)?.doubleValue.map { CGFloat($0) }
|
|
41
|
+
let decodeTarget = max(maxWidth ?? defaultMaxDimension, maxHeight ?? defaultMaxDimension) * 2
|
|
42
|
+
|
|
43
|
+
let thumbnailOptions: [CFString: Any] = [
|
|
44
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
45
|
+
kCGImageSourceThumbnailMaxPixelSize: decodeTarget,
|
|
46
|
+
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
47
|
+
]
|
|
48
|
+
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
|
|
49
|
+
throw PhotoExportError(code: "E_OUT_OF_MEMORY", message: "The photo is too large to process on this device.")
|
|
50
|
+
}
|
|
51
|
+
let upright = UIImage(cgImage: cgImage)
|
|
52
|
+
|
|
53
|
+
let transformed = PhotoEditSession.applyTransform(upright, state: transform)
|
|
54
|
+
let adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments: adjustments)
|
|
55
|
+
let layered = PhotoLayerRenderer.render(adjusted, layers: layers) { uri in resolveImageLayer(uri) }
|
|
56
|
+
let cropped = crop(layered, transform: transform)
|
|
57
|
+
let resized = resize(cropped, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
58
|
+
|
|
59
|
+
let format = (exportOptions?["imageFormat"] as? String)?.lowercased() ?? "jpeg"
|
|
60
|
+
let quality = qualityFor(exportOptions?["quality"] as? String ?? "high")
|
|
61
|
+
|
|
62
|
+
let outputDir = FileManager.default.temporaryDirectory.appendingPathComponent("photovideoeditor", isDirectory: true)
|
|
63
|
+
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
|
64
|
+
|
|
65
|
+
let data: Data?
|
|
66
|
+
let mimeType: String
|
|
67
|
+
let ext: String
|
|
68
|
+
switch format {
|
|
69
|
+
case "png":
|
|
70
|
+
data = resized.pngData()
|
|
71
|
+
mimeType = "image/png"
|
|
72
|
+
ext = "png"
|
|
73
|
+
default:
|
|
74
|
+
// WebP encoding is not implemented on iOS; unsupported formats fall back to JPEG.
|
|
75
|
+
data = resized.jpegData(compressionQuality: quality)
|
|
76
|
+
mimeType = "image/jpeg"
|
|
77
|
+
ext = "jpg"
|
|
78
|
+
}
|
|
79
|
+
guard let outputData = data else {
|
|
80
|
+
throw PhotoExportError(code: "E_EXPORT_FAILED", message: "Unable to encode the exported photo.")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let outputFile = outputDir.appendingPathComponent(UUID().uuidString + "." + ext)
|
|
84
|
+
do {
|
|
85
|
+
try outputData.write(to: outputFile)
|
|
86
|
+
} catch {
|
|
87
|
+
throw PhotoExportError(code: "E_EXPORT_FAILED", message: "Unable to write the exported photo.")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: outputFile.path)
|
|
91
|
+
let fileSize = (attributes?[.size] as? Int) ?? outputData.count
|
|
92
|
+
|
|
93
|
+
return PhotoExportResult(
|
|
94
|
+
uri: outputFile.absoluteString,
|
|
95
|
+
width: Int(resized.size.width),
|
|
96
|
+
height: Int(resized.size.height),
|
|
97
|
+
fileSize: fileSize,
|
|
98
|
+
mimeType: mimeType
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Export-time, one-shot resolver for sticker-uri and overlay-uri layers alike (no cache needed —
|
|
103
|
+
/// export runs once per invocation).
|
|
104
|
+
private static func resolveImageLayer(_ uri: String) -> UIImage? {
|
|
105
|
+
guard let path = SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_layer_export") else { return nil }
|
|
106
|
+
return UIImage(contentsOfFile: path)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private static func crop(_ image: UIImage, transform: PhotoTransformState) -> UIImage {
|
|
110
|
+
guard transform.cropLeft > 0 || transform.cropTop > 0 || transform.cropRight < 1 || transform.cropBottom < 1 else { return image }
|
|
111
|
+
guard let cgImage = image.cgImage else { return image }
|
|
112
|
+
let width = CGFloat(cgImage.width)
|
|
113
|
+
let height = CGFloat(cgImage.height)
|
|
114
|
+
let fullRect = CGRect(x: 0, y: 0, width: width, height: height)
|
|
115
|
+
let rect = CGRect(
|
|
116
|
+
x: transform.cropLeft * width,
|
|
117
|
+
y: transform.cropTop * height,
|
|
118
|
+
width: (transform.cropRight - transform.cropLeft) * width,
|
|
119
|
+
height: (transform.cropBottom - transform.cropTop) * height
|
|
120
|
+
).integral.intersection(fullRect)
|
|
121
|
+
guard rect.width > 0, rect.height > 0, let croppedImage = cgImage.cropping(to: rect) else { return image }
|
|
122
|
+
return UIImage(cgImage: croppedImage)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private static func resize(_ image: UIImage, maxWidth: CGFloat?, maxHeight: CGFloat?) -> UIImage {
|
|
126
|
+
guard maxWidth != nil || maxHeight != nil else { return image }
|
|
127
|
+
let widthLimit = maxWidth ?? image.size.width
|
|
128
|
+
let heightLimit = maxHeight ?? image.size.height
|
|
129
|
+
guard image.size.width > widthLimit || image.size.height > heightLimit else { return image }
|
|
130
|
+
let scale = min(widthLimit / image.size.width, heightLimit / image.size.height)
|
|
131
|
+
let targetSize = CGSize(width: max(1, image.size.width * scale), height: max(1, image.size.height * scale))
|
|
132
|
+
let renderer = PhotoEditSession.pixelRenderer(size: targetSize)
|
|
133
|
+
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private static func qualityFor(_ preset: String) -> CGFloat {
|
|
137
|
+
switch preset {
|
|
138
|
+
case "low": return 0.5
|
|
139
|
+
case "medium": return 0.75
|
|
140
|
+
case "original": return 1.0
|
|
141
|
+
default: return 0.9
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
/// Pure normalized-coordinate mapping used by preview and export rendering.
|
|
4
|
+
enum OverlayGeometry {
|
|
5
|
+
struct Transform {
|
|
6
|
+
let center: CGPoint
|
|
7
|
+
let scale: CGFloat
|
|
8
|
+
let rotationDegrees: CGFloat
|
|
9
|
+
let opacity: CGFloat
|
|
10
|
+
let shortSide: CGFloat
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static func map(_ layer: PhotoLayer, frameSize: CGSize) -> Transform {
|
|
14
|
+
Transform(
|
|
15
|
+
center: CGPoint(x: layer.x * frameSize.width, y: layer.y * frameSize.height),
|
|
16
|
+
scale: layer.scale,
|
|
17
|
+
rotationDegrees: layer.rotationDegrees,
|
|
18
|
+
opacity: min(max(layer.opacity, 0), 1),
|
|
19
|
+
shortSide: min(frameSize.width, frameSize.height)
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Applies the non-destructive `PhotoAdjustments` stack to a `UIImage`. The
|
|
4
|
+
/// live preview and the exporter both call this, in this exact order, so what
|
|
5
|
+
/// the user sees always matches what gets exported — and the per-channel
|
|
6
|
+
/// formulas mirror `PhotoAdjustmentRenderer.kt` so Android/iOS output stays
|
|
7
|
+
/// visually close:
|
|
8
|
+
///
|
|
9
|
+
/// color adjustments (brightness/contrast/saturation/exposure/temperature)
|
|
10
|
+
/// -> blur -> mirror -> preset filter blend.
|
|
11
|
+
enum PhotoAdjustmentRenderer {
|
|
12
|
+
static func apply(_ source: UIImage, adjustments: PhotoAdjustments) -> UIImage {
|
|
13
|
+
guard !adjustments.isIdentity, var buffer = PixelBuffer(image: source) else { return source }
|
|
14
|
+
|
|
15
|
+
let needsColorPass = adjustments.brightness != 0 || adjustments.contrast != 0 || adjustments.saturation != 0 ||
|
|
16
|
+
adjustments.exposure != 0 || adjustments.temperature != 0
|
|
17
|
+
if needsColorPass {
|
|
18
|
+
applyColorAdjustments(&buffer, adjustments)
|
|
19
|
+
}
|
|
20
|
+
if adjustments.blurRadius >= 1 {
|
|
21
|
+
applyBoxBlur(&buffer, radius: Int(adjustments.blurRadius.rounded()))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var image = buffer.makeImage() ?? source
|
|
25
|
+
if adjustments.mirror {
|
|
26
|
+
image = applyMirror(image)
|
|
27
|
+
}
|
|
28
|
+
if let preset = adjustments.filterPreset {
|
|
29
|
+
image = applyPreset(image, preset: preset, strength: adjustments.filterStrength / 100)
|
|
30
|
+
}
|
|
31
|
+
return image
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static func applyColorAdjustments(_ buffer: inout PixelBuffer, _ adjustments: PhotoAdjustments) {
|
|
35
|
+
let exposureScale = pow(2, adjustments.exposure / 100)
|
|
36
|
+
let brightnessOffset = adjustments.brightness / 100 * 80
|
|
37
|
+
let contrastScale = (adjustments.contrast + 100) / 100
|
|
38
|
+
let contrastTranslate = 128 * (1 - contrastScale)
|
|
39
|
+
let saturationFactor = (adjustments.saturation + 100) / 100
|
|
40
|
+
let tempShift = adjustments.temperature / 100 * 40
|
|
41
|
+
|
|
42
|
+
for y in 0..<buffer.height {
|
|
43
|
+
for x in 0..<buffer.width {
|
|
44
|
+
let pixel = buffer[x, y]
|
|
45
|
+
var r = Double(pixel.r) * exposureScale + brightnessOffset
|
|
46
|
+
var g = Double(pixel.g) * exposureScale + brightnessOffset
|
|
47
|
+
var b = Double(pixel.b) * exposureScale + brightnessOffset
|
|
48
|
+
r = r * contrastScale + contrastTranslate
|
|
49
|
+
g = g * contrastScale + contrastTranslate
|
|
50
|
+
b = b * contrastScale + contrastTranslate
|
|
51
|
+
let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
52
|
+
r = luminance + (r - luminance) * saturationFactor
|
|
53
|
+
g = luminance + (g - luminance) * saturationFactor
|
|
54
|
+
b = luminance + (b - luminance) * saturationFactor
|
|
55
|
+
r += tempShift
|
|
56
|
+
b -= tempShift
|
|
57
|
+
buffer[x, y] = (clampByte(r), clampByte(g), clampByte(b), pixel.a)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Approximate Gaussian blur via three passes of separable box blur.
|
|
63
|
+
private static func applyBoxBlur(_ buffer: inout PixelBuffer, radius: Int) {
|
|
64
|
+
guard radius > 0 else { return }
|
|
65
|
+
for _ in 0..<3 {
|
|
66
|
+
boxBlurPass(&buffer, radius: radius, horizontal: true)
|
|
67
|
+
boxBlurPass(&buffer, radius: radius, horizontal: false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private static func boxBlurPass(_ buffer: inout PixelBuffer, radius: Int, horizontal: Bool) {
|
|
72
|
+
let source = buffer
|
|
73
|
+
let outerLimit = horizontal ? buffer.height : buffer.width
|
|
74
|
+
let innerLimit = horizontal ? buffer.width : buffer.height
|
|
75
|
+
for outer in 0..<outerLimit {
|
|
76
|
+
for inner in 0..<innerLimit {
|
|
77
|
+
var r = 0
|
|
78
|
+
var g = 0
|
|
79
|
+
var b = 0
|
|
80
|
+
var a = 0
|
|
81
|
+
var count = 0
|
|
82
|
+
for offset in -radius...radius {
|
|
83
|
+
let sample = min(max(inner + offset, 0), innerLimit - 1)
|
|
84
|
+
let (x, y) = horizontal ? (sample, outer) : (outer, sample)
|
|
85
|
+
let pixel = source[x, y]
|
|
86
|
+
r += Int(pixel.r); g += Int(pixel.g); b += Int(pixel.b); a += Int(pixel.a)
|
|
87
|
+
count += 1
|
|
88
|
+
}
|
|
89
|
+
let (ox, oy) = horizontal ? (inner, outer) : (outer, inner)
|
|
90
|
+
buffer[ox, oy] = (UInt8(r / count), UInt8(g / count), UInt8(b / count), UInt8(a / count))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Mirrors the left half of the image onto the right half.
|
|
96
|
+
private static func applyMirror(_ image: UIImage) -> UIImage {
|
|
97
|
+
guard image.size.width >= 2, let cgImage = image.cgImage else { return image }
|
|
98
|
+
let halfWidth = CGFloat(Int(image.size.width) / 2)
|
|
99
|
+
let leftRect = CGRect(x: 0, y: 0, width: halfWidth, height: image.size.height)
|
|
100
|
+
guard let leftCG = cgImage.cropping(to: leftRect) else { return image }
|
|
101
|
+
let leftImage = UIImage(cgImage: leftCG)
|
|
102
|
+
let renderer = PhotoEditSession.pixelRenderer(size: image.size)
|
|
103
|
+
return renderer.image { context in
|
|
104
|
+
leftImage.draw(in: leftRect)
|
|
105
|
+
context.cgContext.saveGState()
|
|
106
|
+
context.cgContext.translateBy(x: image.size.width, y: 0)
|
|
107
|
+
context.cgContext.scaleBy(x: -1, y: 1)
|
|
108
|
+
leftImage.draw(in: leftRect)
|
|
109
|
+
context.cgContext.restoreGState()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private static func applyPreset(_ image: UIImage, preset: String, strength: Double) -> UIImage {
|
|
114
|
+
guard let recipe = PhotoFilterPresets.recipe(for: preset), var buffer = PixelBuffer(image: image) else { return image }
|
|
115
|
+
for y in 0..<buffer.height {
|
|
116
|
+
for x in 0..<buffer.width {
|
|
117
|
+
let pixel = buffer[x, y]
|
|
118
|
+
let luminance = 0.2126 * Double(pixel.r) + 0.7152 * Double(pixel.g) + 0.0722 * Double(pixel.b)
|
|
119
|
+
var r = luminance + (Double(pixel.r) - luminance) * recipe.saturation
|
|
120
|
+
var g = luminance + (Double(pixel.g) - luminance) * recipe.saturation
|
|
121
|
+
var b = luminance + (Double(pixel.b) - luminance) * recipe.saturation
|
|
122
|
+
r = r * recipe.scale + recipe.redOffset
|
|
123
|
+
g = g * recipe.scale + recipe.greenOffset
|
|
124
|
+
b = b * recipe.scale + recipe.blueOffset
|
|
125
|
+
buffer[x, y] = (clampByte(r), clampByte(g), clampByte(b), pixel.a)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
guard let filtered = buffer.makeImage() else { return image }
|
|
129
|
+
guard strength < 1 else { return filtered }
|
|
130
|
+
|
|
131
|
+
let renderer = PhotoEditSession.pixelRenderer(size: image.size)
|
|
132
|
+
return renderer.image { _ in
|
|
133
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
134
|
+
filtered.draw(in: CGRect(origin: .zero, size: image.size), blendMode: .normal, alpha: CGFloat(strength))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private static func clampByte(_ value: Double) -> UInt8 {
|
|
139
|
+
UInt8(min(max(value.rounded(), 0), 255))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Non-destructive photo adjustment stack (Milestone 3). Every numeric field
|
|
4
|
+
/// has a documented range and a zero/no-op default so "reset" is always just
|
|
5
|
+
/// `PhotoAdjustments()`. Mirrors `PhotoAdjustments.kt` field-for-field so the
|
|
6
|
+
/// two platforms can share the same formulas in their renderers.
|
|
7
|
+
struct PhotoAdjustments: Equatable {
|
|
8
|
+
var brightness: Double = 0 // -100..100
|
|
9
|
+
var contrast: Double = 0 // -100..100
|
|
10
|
+
var saturation: Double = 0 // -100..100
|
|
11
|
+
var exposure: Double = 0 // -100..100
|
|
12
|
+
var temperature: Double = 0 // -100..100
|
|
13
|
+
var blurRadius: Double = 0 // 0..25 (px)
|
|
14
|
+
var mirror: Bool = false
|
|
15
|
+
var filterPreset: String?
|
|
16
|
+
var filterStrength: Double = 100 // 0..100, only meaningful when filterPreset is set
|
|
17
|
+
|
|
18
|
+
var isIdentity: Bool { self == PhotoAdjustments() }
|
|
19
|
+
|
|
20
|
+
func value(_ key: String) -> Double {
|
|
21
|
+
switch key {
|
|
22
|
+
case "brightness": return brightness
|
|
23
|
+
case "contrast": return contrast
|
|
24
|
+
case "saturation": return saturation
|
|
25
|
+
case "exposure": return exposure
|
|
26
|
+
case "temperature": return temperature
|
|
27
|
+
case "blurRadius": return blurRadius
|
|
28
|
+
case "filterStrength": return filterStrength
|
|
29
|
+
default: return 0
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
mutating func setValue(_ key: String, _ newValue: Double) {
|
|
34
|
+
switch key {
|
|
35
|
+
case "brightness": brightness = newValue
|
|
36
|
+
case "contrast": contrast = newValue
|
|
37
|
+
case "saturation": saturation = newValue
|
|
38
|
+
case "exposure": exposure = newValue
|
|
39
|
+
case "temperature": temperature = newValue
|
|
40
|
+
case "blurRadius": blurRadius = newValue
|
|
41
|
+
case "filterStrength": filterStrength = newValue
|
|
42
|
+
default: break
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static let sliderKeys = [
|
|
47
|
+
"brightness", "contrast", "saturation", "exposure", "temperature", "blurRadius",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
static func range(for key: String) -> (Double, Double) {
|
|
51
|
+
switch key {
|
|
52
|
+
case "blurRadius": return (0, 25)
|
|
53
|
+
case "filterStrength": return (0, 100)
|
|
54
|
+
default: return (-100, 100)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import ImageIO
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Owns an in-memory, downsampled working copy of the source photo for fast,
|
|
5
|
+
/// memory-safe live preview. The full-resolution source is decoded again,
|
|
6
|
+
/// once, only at export time by `PhotoExporter`.
|
|
7
|
+
final class PhotoEditSession {
|
|
8
|
+
private(set) var state = PhotoTransformState()
|
|
9
|
+
private(set) var adjustments = PhotoAdjustments()
|
|
10
|
+
let layerStack = PhotoLayerStack()
|
|
11
|
+
let baseImage: UIImage?
|
|
12
|
+
|
|
13
|
+
private var cachedBaseImage: UIImage?
|
|
14
|
+
private var cachedState: PhotoTransformState?
|
|
15
|
+
private var cachedAdjustments: PhotoAdjustments?
|
|
16
|
+
private var cachedPreviewImage: UIImage?
|
|
17
|
+
private weak var cachedPreviewBaseImage: UIImage?
|
|
18
|
+
private var cachedPreviewLayerRevision: UInt64?
|
|
19
|
+
/// Resolved images for both sticker-uri and overlay-uri layers — the lookup logic is identical
|
|
20
|
+
/// for either layer type, so one cache/resolver serves both.
|
|
21
|
+
private var imageLayerCache: [String: UIImage?] = [:]
|
|
22
|
+
|
|
23
|
+
init(sourceUri: String, maxPreviewDimension: CGFloat = 1600) {
|
|
24
|
+
baseImage = PhotoEditSession.decodeUpright(sourceUri: sourceUri, maxDimension: maxPreviewDimension)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func update(_ transform: (inout PhotoTransformState) -> Void) {
|
|
28
|
+
transform(&state)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func updateAdjustments(_ transform: (inout PhotoAdjustments) -> Void) {
|
|
32
|
+
transform(&adjustments)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Rotate/flip/straighten + adjustments only, cached so dragging a layer doesn't re-run the full pipeline every frame.
|
|
36
|
+
private func computeBase() -> UIImage? {
|
|
37
|
+
guard let base = baseImage else { return nil }
|
|
38
|
+
if let cached = cachedBaseImage, cachedState == state, cachedAdjustments == adjustments {
|
|
39
|
+
return cached
|
|
40
|
+
}
|
|
41
|
+
let transformed: UIImage
|
|
42
|
+
if state.rotationDegrees == 0 && state.straightenDegrees == 0 {
|
|
43
|
+
transformed = base
|
|
44
|
+
} else {
|
|
45
|
+
transformed = PhotoEditSession.applyTransform(base, state: state)
|
|
46
|
+
}
|
|
47
|
+
let adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments: adjustments)
|
|
48
|
+
cachedBaseImage = adjusted
|
|
49
|
+
cachedState = state
|
|
50
|
+
cachedAdjustments = adjustments
|
|
51
|
+
return adjusted
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Renders rotate/flip/straighten + adjustments + layers — the crop overlay draws the crop live on top of this.
|
|
55
|
+
func renderPreview() -> UIImage? {
|
|
56
|
+
guard let base = computeBase() else { return nil }
|
|
57
|
+
if cachedPreviewBaseImage === base,
|
|
58
|
+
cachedPreviewLayerRevision == layerStack.revision,
|
|
59
|
+
let cachedPreviewImage {
|
|
60
|
+
return cachedPreviewImage
|
|
61
|
+
}
|
|
62
|
+
let rendered = PhotoLayerRenderer.render(base, layers: layerStack.layers) { [weak self] uri in self?.resolveImageLayer(uri) }
|
|
63
|
+
cachedPreviewBaseImage = base
|
|
64
|
+
cachedPreviewLayerRevision = layerStack.revision
|
|
65
|
+
cachedPreviewImage = rendered
|
|
66
|
+
return rendered
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func release() {
|
|
70
|
+
cachedBaseImage = nil
|
|
71
|
+
cachedState = nil
|
|
72
|
+
cachedAdjustments = nil
|
|
73
|
+
cachedPreviewImage = nil
|
|
74
|
+
cachedPreviewBaseImage = nil
|
|
75
|
+
cachedPreviewLayerRevision = nil
|
|
76
|
+
imageLayerCache.removeAll()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func resolveImageLayer(_ uri: String) -> UIImage? {
|
|
80
|
+
if let cached = imageLayerCache[uri] { return cached }
|
|
81
|
+
let path = SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_layer_image")
|
|
82
|
+
let image = path.flatMap { UIImage(contentsOfFile: $0) }
|
|
83
|
+
imageLayerCache[uri] = image
|
|
84
|
+
return image
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// A renderer whose output pixel size exactly matches its point size, so
|
|
88
|
+
/// width/height math stays consistent with `CGImage.width`/`height` elsewhere.
|
|
89
|
+
static func pixelRenderer(size: CGSize) -> UIGraphicsImageRenderer {
|
|
90
|
+
let format = UIGraphicsImageRendererFormat()
|
|
91
|
+
format.scale = 1
|
|
92
|
+
return UIGraphicsImageRenderer(size: size, format: format)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static func applyTransform(_ image: UIImage, state: PhotoTransformState) -> UIImage {
|
|
96
|
+
guard state.rotationDegrees != 0 || state.straightenDegrees != 0 else { return image }
|
|
97
|
+
let radians = state.totalRotationRadians
|
|
98
|
+
let rotatedBounds = CGRect(origin: .zero, size: image.size).applying(CGAffineTransform(rotationAngle: radians))
|
|
99
|
+
let canvasSize = CGSize(width: abs(rotatedBounds.width), height: abs(rotatedBounds.height))
|
|
100
|
+
let renderer = pixelRenderer(size: canvasSize)
|
|
101
|
+
return renderer.image { context in
|
|
102
|
+
let ctx = context.cgContext
|
|
103
|
+
ctx.translateBy(x: canvasSize.width / 2, y: canvasSize.height / 2)
|
|
104
|
+
ctx.rotate(by: radians)
|
|
105
|
+
image.draw(in: CGRect(x: -image.size.width / 2, y: -image.size.height / 2, width: image.size.width, height: image.size.height))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private static func decodeUpright(sourceUri: String, maxDimension: CGFloat) -> UIImage? {
|
|
110
|
+
guard let path = SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_preview") else { return nil }
|
|
111
|
+
let url = URL(fileURLWithPath: path)
|
|
112
|
+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
|
113
|
+
let options: [CFString: Any] = [
|
|
114
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
115
|
+
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
|
|
116
|
+
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
117
|
+
]
|
|
118
|
+
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
|
|
119
|
+
return UIImage(cgImage: cgImage)
|
|
120
|
+
}
|
|
121
|
+
}
|