Show editable segment window timeline
This commit is contained in:
@@ -368,18 +368,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func clipEditor(_ clip: GrowthLapseProjectClip) -> some View {
|
private func clipEditor(_ clip: GrowthLapseProjectClip) -> some View {
|
||||||
let maxStart = max(0, clip.duration - clip.segmentLength)
|
let liveClip = currentClip(id: clip.id) ?? clip
|
||||||
|
let maxStart = max(0, liveClip.duration - liveClip.segmentLength)
|
||||||
let startBinding = Binding<Double>(
|
let startBinding = Binding<Double>(
|
||||||
get: { currentClip(id: clip.id)?.segmentStart ?? clip.segmentStart },
|
get: { currentClip(id: clip.id)?.segmentStart ?? clip.segmentStart },
|
||||||
set: { processor.updateClip(clip, segmentStart: $0) }
|
set: { processor.updateClip(clip, segmentStart: $0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
ClipPreviewView(clip: currentClip(id: clip.id) ?? clip)
|
ClipPreviewView(clip: liveClip)
|
||||||
.frame(height: 180)
|
.frame(height: 180)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
|
||||||
Text(clip.displayName)
|
Text(liveClip.displayName)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|
||||||
@@ -391,7 +392,16 @@ struct ContentView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Slider(value: startBinding, in: 0...maxStart, step: 0.1)
|
SegmentTimelineView(clip: liveClip)
|
||||||
|
.frame(height: 42)
|
||||||
|
.help("Blau ist das verwendete Segmentfenster. Der markierte rechte Bereich kann nicht als Startpunkt gewählt werden, weil das Segment sonst über das Videoende hinauslaufen würde.")
|
||||||
|
|
||||||
|
if maxStart > 0 {
|
||||||
|
Slider(value: startBinding, in: 0...maxStart, step: 0.1)
|
||||||
|
} else {
|
||||||
|
Slider(value: .constant(0), in: 0...1)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("-1 s") {
|
Button("-1 s") {
|
||||||
@@ -401,7 +411,7 @@ struct ContentView: View {
|
|||||||
processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 0.5)
|
processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 0.5)
|
||||||
}
|
}
|
||||||
Button("Zur Mitte") {
|
Button("Zur Mitte") {
|
||||||
processor.updateClip(clip, segmentStart: max(0, (clip.duration - clip.segmentLength) / 2 + settings.startOffset))
|
processor.updateClip(clip, segmentStart: max(0, (liveClip.duration - liveClip.segmentLength) / 2 + settings.startOffset))
|
||||||
}
|
}
|
||||||
Button("+0,5 s") {
|
Button("+0,5 s") {
|
||||||
processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 0.5)
|
processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 0.5)
|
||||||
@@ -410,7 +420,7 @@ struct ContentView: View {
|
|||||||
processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1)
|
processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(processor.state.isRunning)
|
.disabled(processor.state.isRunning || maxStart <= 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
@@ -554,3 +564,64 @@ private struct ClipPreviewView: View {
|
|||||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SegmentTimelineView: View {
|
||||||
|
let clip: GrowthLapseProjectClip
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let width = max(1, proxy.size.width)
|
||||||
|
let duration = max(0.001, clip.duration)
|
||||||
|
let maxStart = max(0, clip.duration - clip.segmentLength)
|
||||||
|
let startX = width * CGFloat(clamp(clip.segmentStart / duration))
|
||||||
|
let segmentWidth = max(3, width * CGFloat(clamp(clip.segmentLength / duration)))
|
||||||
|
let invalidStartX = width * CGFloat(clamp(maxStart / duration))
|
||||||
|
let invalidWidth = max(0, width - invalidStartX)
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(nsColor: .separatorColor).opacity(0.35))
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.orange.opacity(0.16))
|
||||||
|
.frame(width: invalidWidth)
|
||||||
|
.offset(x: invalidStartX)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.accentColor.opacity(0.75))
|
||||||
|
.frame(width: min(segmentWidth, width - startX))
|
||||||
|
.offset(x: startX)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 2)
|
||||||
|
.offset(x: startX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 14)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("0 s")
|
||||||
|
Spacer()
|
||||||
|
Text("Segment \(format(clip.segmentStart))-\(format(min(clip.duration, clip.segmentStart + clip.segmentLength))) s")
|
||||||
|
Spacer()
|
||||||
|
Text("\(format(clip.duration)) s")
|
||||||
|
}
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clamp(_ value: Double) -> Double {
|
||||||
|
min(1, max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ value: Double) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.minimumFractionDigits = value.rounded() == value ? 0 : 1
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user