Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Sources/PalmierPro/Preview/PreviewContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ struct PreviewContainerView: View {
private var isImage: Bool { editor.activePreviewTab.clipType == .image }

@State private var hoveredTabId: String?
@State private var activeGuides: Set<ViewerGuide> = []

private var guidesDefaultsKey: String {
"viewerGuides.\(editor.projectId ?? "default")"
}

private func loadGuides() {
let saved = UserDefaults.standard.stringArray(forKey: guidesDefaultsKey) ?? []
activeGuides = Set(saved.compactMap(ViewerGuide.init(rawValue:)))
}

private func saveGuides() {
UserDefaults.standard.set(activeGuides.map(\.rawValue), forKey: guidesDefaultsKey)
}

var body: some View {
VStack(spacing: 0) {
Expand All @@ -34,6 +48,9 @@ struct PreviewContainerView: View {
if let overlay = offlineOverlay {
offlinePreview(assetId: overlay.assetId, path: overlay.path, isUnprocessable: overlay.isUnprocessable)
}
if !activeGuides.isEmpty {
ViewerGuidesOverlay(activeGuides: activeGuides)
}
if editor.cropEditingActive {
CropOverlayView()
} else {
Expand All @@ -57,6 +74,9 @@ struct PreviewContainerView: View {
}
}
.background(AppTheme.Background.surfaceColor)
.onAppear { loadGuides() }
.onChange(of: editor.projectId) { loadGuides() }
.onChange(of: activeGuides) { saveGuides() }
}

// MARK: - Transport bar
Expand Down Expand Up @@ -554,6 +574,7 @@ struct PreviewContainerView: View {
}
}

guidesToggleButton
overflowMenu
}
}
Expand Down Expand Up @@ -627,6 +648,40 @@ struct PreviewContainerView: View {
.help("More")
}

private var guidesToggleButton: some View {
Menu {
Section("Safe Zones") {
ForEach([ViewerGuide.actionSafe, .titleSafe, .center]) { guide in
Toggle(guide.displayName, isOn: guideBinding(for: guide))
}
}
Section("Format Reference") {
ForEach([ViewerGuide.scope, .wide, .square, .portrait]) { guide in
Toggle(guide.displayName, isOn: guideBinding(for: guide))
}
}
} label: {
Image(systemName: activeGuides.isEmpty ? "viewfinder" : "viewfinder.circle.fill")
.font(.system(size: AppTheme.FontSize.sm, weight: .medium))
.foregroundStyle(activeGuides.isEmpty ? AppTheme.Text.secondaryColor : AppTheme.Accent.primary)
.frame(width: AppTheme.IconSize.md, height: AppTheme.IconSize.md)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.hoverHighlight(cornerRadius: AppTheme.Radius.sm)
.help("Viewer Guides")
}

private func guideBinding(for guide: ViewerGuide) -> Binding<Bool> {
Binding(
get: { activeGuides.contains(guide) },
set: { on in
if on { activeGuides.insert(guide) } else { activeGuides.remove(guide) }
}
)
}

private func closeButton(tabId: String) -> some View {
Button {
withAnimation(.easeInOut(duration: AppTheme.Anim.transition)) {
Expand Down
46 changes: 46 additions & 0 deletions Sources/PalmierPro/Preview/ViewerGuide.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

enum ViewerGuide: String, CaseIterable, Identifiable {
case actionSafe
case titleSafe
case center
case scope
case wide
case square
case portrait

var id: String { rawValue }

var displayName: String {
switch self {
case .actionSafe: return "Action Safe"
case .titleSafe: return "Title Safe"
case .center: return "Center"
case .scope: return "Scope (2.39:1)"
case .wide: return "Wide (1.85:1)"
case .square: return "Square (1:1)"
case .portrait: return "Portrait (9:16)"
}
}

// Inset fraction per side for safe-zone guides (SMPTE ST 2046-1 / ITU-R BT.1848-1).
// Action Safe = 93% of frame → 3.5% inset each side.
// Title Safe = 90% of frame → 5% inset each side.
var safeZoneInset: CGFloat? {
switch self {
case .actionSafe: return 0.035
case .titleSafe: return 0.05
default: return nil
}
}

var formatAspect: CGFloat? {
switch self {
case .scope: return 2.39
case .wide: return 1.85
case .square: return 1.0
case .portrait: return 9.0 / 16.0
default: return nil
}
}
}
82 changes: 82 additions & 0 deletions Sources/PalmierPro/Preview/ViewerGuidesOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import SwiftUI

struct ViewerGuidesOverlay: View {
let activeGuides: Set<ViewerGuide>

var body: some View {
Canvas { ctx, size in
for guide in activeGuides {
switch guide {
case .actionSafe, .titleSafe:
drawSafeZone(guide, ctx: &ctx, size: size)
case .center:
drawCenter(ctx: &ctx, size: size)
case .scope, .wide, .square, .portrait:
drawFormatBars(guide, ctx: &ctx, size: size)
}
}
}
.allowsHitTesting(false)
}

private func drawSafeZone(_ guide: ViewerGuide, ctx: inout GraphicsContext, size: CGSize) {
guard let inset = guide.safeZoneInset else { return }
let dx = size.width * inset
let dy = size.height * inset
let rect = CGRect(x: dx, y: dy, width: size.width - dx * 2, height: size.height - dy * 2)
var path = Path()
path.addRect(rect)
ctx.stroke(
path,
with: .color(.white.opacity(AppTheme.Opacity.strong)),
style: StrokeStyle(lineWidth: AppTheme.BorderWidth.thin, dash: [4, 3])
)
}

private func drawCenter(ctx: inout GraphicsContext, size: CGSize) {
let cx = size.width / 2
let cy = size.height / 2
let arm: CGFloat = 14
var path = Path()
path.move(to: CGPoint(x: cx - arm, y: cy))
path.addLine(to: CGPoint(x: cx + arm, y: cy))
path.move(to: CGPoint(x: cx, y: cy - arm))
path.addLine(to: CGPoint(x: cx, y: cy + arm))
ctx.stroke(path, with: .color(.white.opacity(AppTheme.Opacity.strong)), lineWidth: AppTheme.BorderWidth.thin)
}

private func drawFormatBars(_ guide: ViewerGuide, ctx: inout GraphicsContext, size: CGSize) {
guard let target = guide.formatAspect else { return }
let current = size.width / size.height
guard abs(current - target) > 0.01 else { return }

let barFill = GraphicsContext.Shading.color(.black.opacity(AppTheme.Opacity.strong))
let edge = GraphicsContext.Shading.color(.white.opacity(AppTheme.Opacity.medium))

if target < current {
// Pillarbox: black bars on left and right
let innerW = size.height * target
let barW = (size.width - innerW) / 2
ctx.fill(Path(CGRect(x: 0, y: 0, width: barW, height: size.height)), with: barFill)
ctx.fill(Path(CGRect(x: size.width - barW, y: 0, width: barW, height: size.height)), with: barFill)
var border = Path()
border.move(to: CGPoint(x: barW, y: 0))
border.addLine(to: CGPoint(x: barW, y: size.height))
border.move(to: CGPoint(x: size.width - barW, y: 0))
border.addLine(to: CGPoint(x: size.width - barW, y: size.height))
ctx.stroke(border, with: edge, lineWidth: AppTheme.BorderWidth.thin)
} else {
// Letterbox: black bars on top and bottom
let innerH = size.width / target
let barH = (size.height - innerH) / 2
ctx.fill(Path(CGRect(x: 0, y: 0, width: size.width, height: barH)), with: barFill)
ctx.fill(Path(CGRect(x: 0, y: size.height - barH, width: size.width, height: barH)), with: barFill)
var border = Path()
border.move(to: CGPoint(x: 0, y: barH))
border.addLine(to: CGPoint(x: size.width, y: barH))
border.move(to: CGPoint(x: 0, y: size.height - barH))
border.addLine(to: CGPoint(x: size.width, y: size.height - barH))
ctx.stroke(border, with: edge, lineWidth: AppTheme.BorderWidth.thin)
}
}
}