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,45 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A small set of original, neutrally named preset filters (no third-party
|
|
4
|
+
/// looks or names copied). Each recipe is applied by the renderer as:
|
|
5
|
+
/// 1) set saturation, 2) scale RGB, 3) add the per-channel offset —
|
|
6
|
+
/// mirroring the matrices in `PhotoFilterPresets.kt`.
|
|
7
|
+
struct PhotoFilterRecipe {
|
|
8
|
+
let scale: Double
|
|
9
|
+
let redOffset: Double
|
|
10
|
+
let greenOffset: Double
|
|
11
|
+
let blueOffset: Double
|
|
12
|
+
let saturation: Double
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
enum PhotoFilterPresets {
|
|
16
|
+
static let presetIDs = ["mono", "noir", "fade", "chrome", "warmth"]
|
|
17
|
+
|
|
18
|
+
static func label(for id: String) -> String {
|
|
19
|
+
switch id {
|
|
20
|
+
case "mono": return "Mono"
|
|
21
|
+
case "noir": return "Noir"
|
|
22
|
+
case "fade": return "Fade"
|
|
23
|
+
case "chrome": return "Chrome"
|
|
24
|
+
case "warmth": return "Warmth"
|
|
25
|
+
default: return id
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static func recipe(for id: String) -> PhotoFilterRecipe? {
|
|
30
|
+
switch id {
|
|
31
|
+
case "mono":
|
|
32
|
+
return PhotoFilterRecipe(scale: 1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 0)
|
|
33
|
+
case "noir":
|
|
34
|
+
return PhotoFilterRecipe(scale: 1.3, redOffset: -40, greenOffset: -40, blueOffset: -40, saturation: 0)
|
|
35
|
+
case "fade":
|
|
36
|
+
return PhotoFilterRecipe(scale: 0.9, redOffset: 25, greenOffset: 25, blueOffset: 25, saturation: 1)
|
|
37
|
+
case "chrome":
|
|
38
|
+
return PhotoFilterRecipe(scale: 1.1, redOffset: 0, greenOffset: 0, blueOffset: 0, saturation: 1.4)
|
|
39
|
+
case "warmth":
|
|
40
|
+
return PhotoFilterRecipe(scale: 1, redOffset: 18, greenOffset: 6, blueOffset: -12, saturation: 1)
|
|
41
|
+
default:
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
enum LayerType {
|
|
4
|
+
case text, sticker, overlay
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/// A single non-destructive overlay layer (text, sticker, or a user-uploaded
|
|
8
|
+
/// overlay image). One flat model covers every type for simplicity — a
|
|
9
|
+
/// documented simplification versus a per-type hierarchy. Mirrors
|
|
10
|
+
/// `PhotoLayer.kt`.
|
|
11
|
+
///
|
|
12
|
+
/// `x`/`y` are the layer's center, normalized 0..1 against the image.
|
|
13
|
+
struct PhotoLayer {
|
|
14
|
+
let id: String
|
|
15
|
+
var type: LayerType
|
|
16
|
+
var x: CGFloat = 0.5
|
|
17
|
+
var y: CGFloat = 0.5
|
|
18
|
+
var scale: CGFloat = 1
|
|
19
|
+
var rotationDegrees: CGFloat = 0
|
|
20
|
+
var opacity: CGFloat = 1
|
|
21
|
+
var locked = false
|
|
22
|
+
var visible = true
|
|
23
|
+
// Text
|
|
24
|
+
var text: String = ""
|
|
25
|
+
var textColor: UIColor = .white
|
|
26
|
+
var fontSize: CGFloat = 48
|
|
27
|
+
var fontFamily: String?
|
|
28
|
+
// Sticker
|
|
29
|
+
var stickerId: String?
|
|
30
|
+
var stickerUri: String?
|
|
31
|
+
// Overlay (user-uploaded image, placed as a fully generic movable/resizable/rotatable layer)
|
|
32
|
+
var overlayUri: String?
|
|
33
|
+
var overlayAspectRatio: CGFloat?
|
|
34
|
+
// Timing (video overlays only; ignored for photo layers). endMs <= 0 means "to end of video".
|
|
35
|
+
var startMs: Int64 = 0
|
|
36
|
+
var endMs: Int64 = 0
|
|
37
|
+
|
|
38
|
+
init(id: String = UUID().uuidString, type: LayerType) {
|
|
39
|
+
self.id = id
|
|
40
|
+
self.type = type
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// True if this layer should be visible at `positionMs` against a video of `durationMs`. Always true for photo layers.
|
|
44
|
+
func effectiveEndMs(durationMs: Int64) -> Int64 {
|
|
45
|
+
(endMs > 0 && endMs <= durationMs) ? endMs : durationMs
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func isActive(atMs positionMs: Int64, durationMs: Int64) -> Bool {
|
|
49
|
+
guard visible, durationMs >= 0 else { return false }
|
|
50
|
+
let clamped = max(positionMs, 0)
|
|
51
|
+
guard clamped >= max(startMs, 0) else { return false }
|
|
52
|
+
// No explicit end (or one at/past the video's end) means "visible through the real last
|
|
53
|
+
// frame" — don't compare against `durationMs`, which is a separately-computed estimate
|
|
54
|
+
// that can be a few ms shy of the true final frame's timestamp due to frame-rate rounding,
|
|
55
|
+
// which would otherwise hide the overlay early.
|
|
56
|
+
if endMs <= 0 || endMs >= durationMs { return true }
|
|
57
|
+
return clamped <= endMs
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Burns the layer stack onto a `UIImage`. Used identically by the live
|
|
4
|
+
/// preview and the exporter (different image resolutions, same code and
|
|
5
|
+
/// normalized coordinates) so what you see matches what gets exported.
|
|
6
|
+
/// Mirrors `PhotoLayerRenderer.kt`.
|
|
7
|
+
enum PhotoLayerRenderer {
|
|
8
|
+
private static let builtinStickers: [String: String] = [
|
|
9
|
+
"smile": "😀", "heart": "❤️", "star": "⭐", "fire": "🔥",
|
|
10
|
+
"thumbsUp": "👍", "sun": "☀️", "clap": "👏", "sparkles": "✨",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
static func builtinStickerIDs() -> [String] { Array(builtinStickers.keys).sorted() }
|
|
14
|
+
static func glyph(for stickerID: String) -> String { builtinStickers[stickerID] ?? "★" }
|
|
15
|
+
|
|
16
|
+
static func render(_ base: UIImage, layers: [PhotoLayer], imageResolver: (String) -> UIImage? = { _ in nil }) -> UIImage {
|
|
17
|
+
guard !layers.isEmpty else { return base }
|
|
18
|
+
let renderer = PhotoEditSession.pixelRenderer(size: base.size)
|
|
19
|
+
return renderer.image { context in
|
|
20
|
+
base.draw(in: CGRect(origin: .zero, size: base.size))
|
|
21
|
+
for layer in layers where layer.visible {
|
|
22
|
+
let geometry = OverlayGeometry.map(layer, frameSize: base.size)
|
|
23
|
+
let ctx = context.cgContext
|
|
24
|
+
ctx.saveGState()
|
|
25
|
+
ctx.translateBy(x: geometry.center.x, y: geometry.center.y)
|
|
26
|
+
ctx.rotate(by: geometry.rotationDegrees * .pi / 180)
|
|
27
|
+
ctx.scaleBy(x: geometry.scale, y: geometry.scale)
|
|
28
|
+
switch layer.type {
|
|
29
|
+
case .text: drawText(layer, shortSide: geometry.shortSide)
|
|
30
|
+
case .sticker: drawSticker(layer, shortSide: geometry.shortSide, resolver: imageResolver)
|
|
31
|
+
case .overlay: drawOverlay(layer, canvasWidth: base.size.width, resolver: imageResolver)
|
|
32
|
+
}
|
|
33
|
+
ctx.restoreGState()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private static func drawText(_ layer: PhotoLayer, shortSide: CGFloat) {
|
|
39
|
+
let fontSize = layer.fontSize / 48 * (shortSide * 0.06)
|
|
40
|
+
let font: UIFont = layer.fontFamily.flatMap { UIFont(name: $0, size: fontSize) } ?? .systemFont(ofSize: fontSize)
|
|
41
|
+
let paragraph = NSMutableParagraphStyle()
|
|
42
|
+
paragraph.alignment = .center
|
|
43
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
44
|
+
.font: font,
|
|
45
|
+
.foregroundColor: layer.textColor.withAlphaComponent(layer.opacity),
|
|
46
|
+
.paragraphStyle: paragraph,
|
|
47
|
+
]
|
|
48
|
+
let text = layer.text.isEmpty ? " " : layer.text
|
|
49
|
+
let attributed = NSAttributedString(string: text, attributes: attributes)
|
|
50
|
+
let size = attributed.size()
|
|
51
|
+
attributed.draw(at: CGPoint(x: -size.width / 2, y: -size.height / 2))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static func drawSticker(_ layer: PhotoLayer, shortSide: CGFloat, resolver: (String) -> UIImage?) {
|
|
55
|
+
let size = shortSide * 0.18
|
|
56
|
+
let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
|
|
57
|
+
if let uri = layer.stickerUri, let image = resolver(uri) {
|
|
58
|
+
image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
let glyph = layer.stickerId.map { PhotoLayerRenderer.glyph(for: $0) } ?? "★"
|
|
62
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
63
|
+
.font: UIFont.systemFont(ofSize: size),
|
|
64
|
+
]
|
|
65
|
+
let attributed = NSAttributedString(string: glyph, attributes: attributes)
|
|
66
|
+
let textSize = attributed.size()
|
|
67
|
+
attributed.draw(at: CGPoint(x: -textSize.width / 2, y: -size / 2), withAlpha: layer.opacity)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Draws a user-uploaded overlay image scaled to fit a box whose width equals the full canvas
|
|
71
|
+
/// width and whose height is derived from `overlayAspectRatio` — the layer's own `scale` (already
|
|
72
|
+
/// applied to the drawing context by `render`) then shrinks/grows it from there. Unlike stickers,
|
|
73
|
+
/// there is no glyph fallback: if the resolver can't produce an image, nothing is drawn.
|
|
74
|
+
private static func drawOverlay(_ layer: PhotoLayer, canvasWidth: CGFloat, resolver: (String) -> UIImage?) {
|
|
75
|
+
guard let uri = layer.overlayUri, let image = resolver(uri) else { return }
|
|
76
|
+
let aspectRatio = layer.overlayAspectRatio.flatMap { $0 > 0 ? $0 : nil } ?? (image.size.width / max(image.size.height, 1))
|
|
77
|
+
let width = canvasWidth
|
|
78
|
+
let height = width / max(aspectRatio, 0.0001)
|
|
79
|
+
let rect = CGRect(x: -width / 2, y: -height / 2, width: width, height: height)
|
|
80
|
+
image.draw(in: rect, blendMode: .normal, alpha: layer.opacity)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private extension NSAttributedString {
|
|
86
|
+
/// `draw(at:)` ignores `withAlpha` unless applied via the graphics context; this composites the whole glyph at `alpha`.
|
|
87
|
+
func draw(at point: CGPoint, withAlpha alpha: CGFloat) {
|
|
88
|
+
guard let ctx = UIGraphicsGetCurrentContext() else { draw(at: point); return }
|
|
89
|
+
ctx.saveGState()
|
|
90
|
+
ctx.setAlpha(alpha)
|
|
91
|
+
draw(at: point)
|
|
92
|
+
ctx.restoreGState()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Ordered layer stack with snapshot-based undo/redo. One entry is pushed per
|
|
4
|
+
/// *committed* operation (add/delete/duplicate/reorder, or a drag/slider
|
|
5
|
+
/// gesture's final value) — never per intermediate touch-move frame — so a
|
|
6
|
+
/// continuous drag does not flood the history. Mirrors `PhotoLayerStack.kt`.
|
|
7
|
+
final class PhotoLayerStack {
|
|
8
|
+
private(set) var layers: [PhotoLayer] = []
|
|
9
|
+
private(set) var revision: UInt64 = 0
|
|
10
|
+
private var undoStack: [[PhotoLayer]] = []
|
|
11
|
+
private var redoStack: [[PhotoLayer]] = []
|
|
12
|
+
private let historyLimit: Int
|
|
13
|
+
|
|
14
|
+
init(historyLimit: Int = 50) {
|
|
15
|
+
self.historyLimit = historyLimit
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var canUndo: Bool { !undoStack.isEmpty }
|
|
19
|
+
var canRedo: Bool { !redoStack.isEmpty }
|
|
20
|
+
|
|
21
|
+
/// Applies `mutation` as a single committed, undoable operation.
|
|
22
|
+
func commit(_ mutation: ([PhotoLayer]) -> [PhotoLayer]) {
|
|
23
|
+
pushUndo(layers)
|
|
24
|
+
redoStack.removeAll()
|
|
25
|
+
layers = mutation(layers)
|
|
26
|
+
revision &+= 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Updates the live layer list without creating a history entry — for in-progress drags.
|
|
30
|
+
func updateLive(_ mutation: ([PhotoLayer]) -> [PhotoLayer]) {
|
|
31
|
+
layers = mutation(layers)
|
|
32
|
+
revision &+= 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Records `previousLayers` (captured before a live-updated gesture began) as one undo step.
|
|
36
|
+
func commitSnapshot(_ previousLayers: [PhotoLayer]) {
|
|
37
|
+
pushUndo(previousLayers)
|
|
38
|
+
redoStack.removeAll()
|
|
39
|
+
revision &+= 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func undo() {
|
|
43
|
+
guard let previous = undoStack.popLast() else { return }
|
|
44
|
+
redoStack.append(layers)
|
|
45
|
+
layers = previous
|
|
46
|
+
revision &+= 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func redo() {
|
|
50
|
+
guard let next = redoStack.popLast() else { return }
|
|
51
|
+
undoStack.append(layers)
|
|
52
|
+
layers = next
|
|
53
|
+
revision &+= 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func pushUndo(_ snapshot: [PhotoLayer]) {
|
|
57
|
+
undoStack.append(snapshot)
|
|
58
|
+
if undoStack.count > historyLimit { undoStack.removeFirst() }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
/// Non-destructive photo edit state. The crop rectangle is normalized (0...1)
|
|
4
|
+
/// against the *transformed* (rotated/straightened) image, matching
|
|
5
|
+
/// what the user sees in the crop overlay — the original source file is never
|
|
6
|
+
/// modified, only read again at export time.
|
|
7
|
+
struct PhotoTransformState: Equatable {
|
|
8
|
+
var cropLeft: CGFloat = 0
|
|
9
|
+
var cropTop: CGFloat = 0
|
|
10
|
+
var cropRight: CGFloat = 1
|
|
11
|
+
var cropBottom: CGFloat = 1
|
|
12
|
+
var rotationDegrees: Int = 0
|
|
13
|
+
var straightenDegrees: CGFloat = 0
|
|
14
|
+
var aspectRatio: CGFloat?
|
|
15
|
+
|
|
16
|
+
var isIdentity: Bool {
|
|
17
|
+
cropLeft == 0 && cropTop == 0 && cropRight == 1 && cropBottom == 1 &&
|
|
18
|
+
rotationDegrees == 0 && straightenDegrees == 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Rotating changes which pixels a normalized crop rect refers to, so the crop resets to full-frame.
|
|
22
|
+
mutating func rotateRight() {
|
|
23
|
+
rotationDegrees = (rotationDegrees + 90) % 360
|
|
24
|
+
cropLeft = 0
|
|
25
|
+
cropTop = 0
|
|
26
|
+
cropRight = 1
|
|
27
|
+
cropBottom = 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
mutating func setStraighten(_ degrees: CGFloat) {
|
|
31
|
+
straightenDegrees = max(-45, min(45, degrees))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mutating func setCrop(left: CGFloat, top: CGFloat, right: CGFloat, bottom: CGFloat) {
|
|
35
|
+
cropLeft = max(0, min(1, left))
|
|
36
|
+
cropTop = max(0, min(1, top))
|
|
37
|
+
cropRight = max(0, min(1, right))
|
|
38
|
+
cropBottom = max(0, min(1, bottom))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mutating func reset() {
|
|
42
|
+
self = PhotoTransformState()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Combined rotate + straighten radians, applied around the image's own center by the caller.
|
|
46
|
+
var totalRotationRadians: CGFloat {
|
|
47
|
+
(CGFloat(rotationDegrees) + straightenDegrees) * .pi / 180
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Raw RGBA8 pixel access for a `UIImage`, used by adjustment/effect passes
|
|
5
|
+
/// that need direct per-pixel math (tone curve, sharpen, box blur).
|
|
6
|
+
///
|
|
7
|
+
/// Important: a bitmap `CGContext` created via `CGContext(data:...)` has its
|
|
8
|
+
/// origin at the bottom-left with Y increasing upward (Quartz-native), which
|
|
9
|
+
/// is the OPPOSITE of the top-down row order `CGImage`/`UIImage` assume.
|
|
10
|
+
/// Drawing into it without first flipping the CTM produces a vertically
|
|
11
|
+
/// flipped image once the raw bytes are wrapped back into a `CGImage`. Both
|
|
12
|
+
/// `init` and `makeImage()` apply that flip so round-tripping through this
|
|
13
|
+
/// buffer preserves the image's orientation.
|
|
14
|
+
struct PixelBuffer {
|
|
15
|
+
private(set) var data: [UInt8]
|
|
16
|
+
let width: Int
|
|
17
|
+
let height: Int
|
|
18
|
+
private static let bytesPerPixel = 4
|
|
19
|
+
private static let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
|
20
|
+
|
|
21
|
+
init?(image: UIImage) {
|
|
22
|
+
guard let cgImage = image.cgImage, cgImage.width > 0, cgImage.height > 0 else { return nil }
|
|
23
|
+
width = cgImage.width
|
|
24
|
+
height = cgImage.height
|
|
25
|
+
var buffer = [UInt8](repeating: 0, count: width * height * Self.bytesPerPixel)
|
|
26
|
+
guard let context = CGContext(
|
|
27
|
+
data: &buffer,
|
|
28
|
+
width: width,
|
|
29
|
+
height: height,
|
|
30
|
+
bitsPerComponent: 8,
|
|
31
|
+
bytesPerRow: width * Self.bytesPerPixel,
|
|
32
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
33
|
+
bitmapInfo: Self.bitmapInfo
|
|
34
|
+
) else { return nil }
|
|
35
|
+
context.translateBy(x: 0, y: CGFloat(height))
|
|
36
|
+
context.scaleBy(x: 1, y: -1)
|
|
37
|
+
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
38
|
+
data = buffer
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func makeImage() -> UIImage? {
|
|
42
|
+
var buffer = data
|
|
43
|
+
guard let context = CGContext(
|
|
44
|
+
data: &buffer,
|
|
45
|
+
width: width,
|
|
46
|
+
height: height,
|
|
47
|
+
bitsPerComponent: 8,
|
|
48
|
+
bytesPerRow: width * Self.bytesPerPixel,
|
|
49
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
50
|
+
bitmapInfo: Self.bitmapInfo
|
|
51
|
+
), let cgImage = context.makeImage() else { return nil }
|
|
52
|
+
return UIImage(cgImage: cgImage)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
subscript(x: Int, y: Int) -> (r: UInt8, g: UInt8, b: UInt8, a: UInt8) {
|
|
56
|
+
get {
|
|
57
|
+
let offset = (y * width + x) * Self.bytesPerPixel
|
|
58
|
+
return (data[offset], data[offset + 1], data[offset + 2], data[offset + 3])
|
|
59
|
+
}
|
|
60
|
+
set {
|
|
61
|
+
let offset = (y * width + x) * Self.bytesPerPixel
|
|
62
|
+
data[offset] = newValue.r
|
|
63
|
+
data[offset + 1] = newValue.g
|
|
64
|
+
data[offset + 2] = newValue.b
|
|
65
|
+
data[offset + 3] = newValue.a
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Draws a draggable/resizable crop rectangle over `imageBounds` (the
|
|
4
|
+
/// on-screen rect of the preview image, in this view's own coordinate space)
|
|
5
|
+
/// and reports the crop edges back normalized to that rect via `onCropChanged`.
|
|
6
|
+
final class CropOverlayView: UIView {
|
|
7
|
+
var onCropChanged: ((_ left: CGFloat, _ top: CGFloat, _ right: CGFloat, _ bottom: CGFloat) -> Void)?
|
|
8
|
+
|
|
9
|
+
private var imageBounds: CGRect = .zero
|
|
10
|
+
private var cropRect: CGRect = .zero
|
|
11
|
+
private var aspectRatio: CGFloat?
|
|
12
|
+
|
|
13
|
+
private let dimLayer = CAShapeLayer()
|
|
14
|
+
private let borderLayer = CAShapeLayer()
|
|
15
|
+
private let handleRadius: CGFloat = 32
|
|
16
|
+
private let minSize: CGFloat = 60
|
|
17
|
+
|
|
18
|
+
private enum Handle { case move, topLeft, topRight, bottomLeft, bottomRight }
|
|
19
|
+
private var activeHandle: Handle?
|
|
20
|
+
private var lastPoint: CGPoint = .zero
|
|
21
|
+
|
|
22
|
+
override init(frame: CGRect) {
|
|
23
|
+
super.init(frame: frame)
|
|
24
|
+
backgroundColor = .clear
|
|
25
|
+
dimLayer.fillRule = .evenOdd
|
|
26
|
+
dimLayer.fillColor = UIColor.black.withAlphaComponent(0.6).cgColor
|
|
27
|
+
layer.addSublayer(dimLayer)
|
|
28
|
+
borderLayer.strokeColor = UIColor.white.cgColor
|
|
29
|
+
borderLayer.fillColor = UIColor.clear.cgColor
|
|
30
|
+
borderLayer.lineWidth = 2
|
|
31
|
+
layer.addSublayer(borderLayer)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
required init?(coder: NSCoder) { nil }
|
|
35
|
+
|
|
36
|
+
/// Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when `resetCrop` is true.
|
|
37
|
+
func setImageBounds(_ bounds: CGRect, resetCrop: Bool) {
|
|
38
|
+
imageBounds = bounds
|
|
39
|
+
if resetCrop || cropRect.width <= 0 || cropRect.height <= 0 {
|
|
40
|
+
cropRect = bounds
|
|
41
|
+
reportCrop()
|
|
42
|
+
}
|
|
43
|
+
setNeedsLayout()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func setAspectRatio(_ ratio: CGFloat?) {
|
|
47
|
+
aspectRatio = ratio
|
|
48
|
+
if let ratio, imageBounds.width > 0, imageBounds.height > 0 {
|
|
49
|
+
let center = CGPoint(x: cropRect.midX, y: cropRect.midY)
|
|
50
|
+
var width = cropRect.width
|
|
51
|
+
var height = width / ratio
|
|
52
|
+
if height > imageBounds.height {
|
|
53
|
+
height = cropRect.height
|
|
54
|
+
width = height * ratio
|
|
55
|
+
}
|
|
56
|
+
cropRect = CGRect(x: center.x - width / 2, y: center.y - height / 2, width: width, height: height)
|
|
57
|
+
clampToImageBounds()
|
|
58
|
+
setNeedsLayout()
|
|
59
|
+
}
|
|
60
|
+
reportCrop()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override func layoutSublayers(of layer: CALayer) {
|
|
64
|
+
super.layoutSublayers(of: layer)
|
|
65
|
+
guard imageBounds.width > 0, imageBounds.height > 0 else { return }
|
|
66
|
+
let outerPath = UIBezierPath(rect: imageBounds)
|
|
67
|
+
outerPath.append(UIBezierPath(rect: cropRect).reversing())
|
|
68
|
+
dimLayer.path = outerPath.cgPath
|
|
69
|
+
|
|
70
|
+
let borderPath = UIBezierPath(rect: cropRect)
|
|
71
|
+
let thirdWidth = cropRect.width / 3
|
|
72
|
+
let thirdHeight = cropRect.height / 3
|
|
73
|
+
for index in 1...2 {
|
|
74
|
+
let x = cropRect.minX + thirdWidth * CGFloat(index)
|
|
75
|
+
borderPath.move(to: CGPoint(x: x, y: cropRect.minY))
|
|
76
|
+
borderPath.addLine(to: CGPoint(x: x, y: cropRect.maxY))
|
|
77
|
+
let y = cropRect.minY + thirdHeight * CGFloat(index)
|
|
78
|
+
borderPath.move(to: CGPoint(x: cropRect.minX, y: y))
|
|
79
|
+
borderPath.addLine(to: CGPoint(x: cropRect.maxX, y: y))
|
|
80
|
+
}
|
|
81
|
+
borderLayer.path = borderPath.cgPath
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
85
|
+
guard let point = touches.first?.location(in: self) else { return }
|
|
86
|
+
activeHandle = hitTest(point)
|
|
87
|
+
lastPoint = point
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
91
|
+
guard let handle = activeHandle, let point = touches.first?.location(in: self) else { return }
|
|
92
|
+
let dx = point.x - lastPoint.x
|
|
93
|
+
let dy = point.y - lastPoint.y
|
|
94
|
+
lastPoint = point
|
|
95
|
+
applyDrag(handle, dx: dx, dy: dy)
|
|
96
|
+
setNeedsLayout()
|
|
97
|
+
reportCrop()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
|
|
101
|
+
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { activeHandle = nil }
|
|
102
|
+
|
|
103
|
+
private func hitTest(_ point: CGPoint) -> Handle? {
|
|
104
|
+
func near(_ target: CGPoint) -> Bool {
|
|
105
|
+
abs(point.x - target.x) < handleRadius && abs(point.y - target.y) < handleRadius
|
|
106
|
+
}
|
|
107
|
+
if near(CGPoint(x: cropRect.minX, y: cropRect.minY)) { return .topLeft }
|
|
108
|
+
if near(CGPoint(x: cropRect.maxX, y: cropRect.minY)) { return .topRight }
|
|
109
|
+
if near(CGPoint(x: cropRect.minX, y: cropRect.maxY)) { return .bottomLeft }
|
|
110
|
+
if near(CGPoint(x: cropRect.maxX, y: cropRect.maxY)) { return .bottomRight }
|
|
111
|
+
if cropRect.contains(point) { return .move }
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func applyDrag(_ handle: Handle, dx: CGFloat, dy: CGFloat) {
|
|
116
|
+
var rect = cropRect
|
|
117
|
+
switch handle {
|
|
118
|
+
case .move:
|
|
119
|
+
rect = rect.offsetBy(dx: dx, dy: dy)
|
|
120
|
+
case .topLeft:
|
|
121
|
+
rect = CGRect(x: rect.minX + dx, y: rect.minY + dy, width: rect.width - dx, height: rect.height - dy)
|
|
122
|
+
case .topRight:
|
|
123
|
+
rect = CGRect(x: rect.minX, y: rect.minY + dy, width: rect.width + dx, height: rect.height - dy)
|
|
124
|
+
case .bottomLeft:
|
|
125
|
+
rect = CGRect(x: rect.minX + dx, y: rect.minY, width: rect.width - dx, height: rect.height + dy)
|
|
126
|
+
case .bottomRight:
|
|
127
|
+
rect = CGRect(x: rect.minX, y: rect.minY, width: rect.width + dx, height: rect.height + dy)
|
|
128
|
+
}
|
|
129
|
+
guard rect.width >= minSize, rect.height >= minSize else { return }
|
|
130
|
+
if let ratio = aspectRatio {
|
|
131
|
+
switch handle {
|
|
132
|
+
case .topLeft, .bottomLeft, .bottomRight:
|
|
133
|
+
rect.size.height = rect.width / ratio
|
|
134
|
+
case .topRight:
|
|
135
|
+
let newHeight = rect.width / ratio
|
|
136
|
+
rect.origin.y = cropRect.maxY - newHeight
|
|
137
|
+
rect.size.height = newHeight
|
|
138
|
+
case .move:
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
cropRect = rect
|
|
143
|
+
clampToImageBounds()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private func clampToImageBounds() {
|
|
147
|
+
if cropRect.minX < imageBounds.minX { cropRect.origin.x = imageBounds.minX }
|
|
148
|
+
if cropRect.minY < imageBounds.minY { cropRect.origin.y = imageBounds.minY }
|
|
149
|
+
if cropRect.maxX > imageBounds.maxX { cropRect.origin.x = imageBounds.maxX - cropRect.width }
|
|
150
|
+
if cropRect.maxY > imageBounds.maxY { cropRect.origin.y = imageBounds.maxY - cropRect.height }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func reportCrop() {
|
|
154
|
+
guard imageBounds.width > 0, imageBounds.height > 0 else { return }
|
|
155
|
+
let left = (cropRect.minX - imageBounds.minX) / imageBounds.width
|
|
156
|
+
let top = (cropRect.minY - imageBounds.minY) / imageBounds.height
|
|
157
|
+
let right = (cropRect.maxX - imageBounds.minX) / imageBounds.width
|
|
158
|
+
let bottom = (cropRect.maxY - imageBounds.minY) / imageBounds.height
|
|
159
|
+
onCropChanged?(left, top, right, bottom)
|
|
160
|
+
}
|
|
161
|
+
}
|