Skip to content

Commit 270b9d5

Browse files
author
Bolyki György
committed
Restore manual export controls
1 parent 97ef868 commit 270b9d5

5 files changed

Lines changed: 211 additions & 120 deletions

File tree

TeslaCam/AppState.swift

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ final class AppState: ObservableObject {
327327
@Published var camerasDetected: [Camera] = []
328328
@Published var duplicatePolicy: DuplicateClipPolicy = .mergeByTime
329329
@Published var selectedExportCameras: Set<Camera> = Set(Camera.allCases)
330+
@Published var exportPreset: ExportPreset = .maxQualityHEVC
330331
@Published var healthSummary: ExportHealthSummary?
331332
@Published var layoutProfile: CameraLayoutProfile = .mixedUnknown
332333
@Published var duplicateSummary = DuplicateResolutionSummary(
@@ -370,11 +371,8 @@ final class AppState: ObservableObject {
370371
)
371372
}
372373
}
373-
// Mux-first default: the common export takes the lossless passthrough path
374-
// (no re-encode), which is both the fastest and an unaltered "evidence" copy.
375-
// Telemetry is always visible in the in-app clip-info dock for review;
376-
// burning it into the file (which forces a composite re-encode) is opt-in via
377-
// privacy mode, camera cuts, or a loaded layout preset.
374+
// Keep telemetry engraving opt-in, but the default export itself stays the
375+
// regular encoded grid video users expect from the app.
378376
@Published var exportOverlayOptions = ExportOverlayOptions(
379377
telemetryHUD: false,
380378
routeMap: false,
@@ -914,6 +912,26 @@ final class AppState: ObservableObject {
914912
)
915913
}
916914

915+
@discardableResult
916+
func applyTrimStartInput(_ text: String) -> Bool {
917+
guard let seconds = parseHMS(text) else { return false }
918+
setTrimRange(
919+
startSeconds: seconds,
920+
endSeconds: trimEndSeconds
921+
)
922+
return true
923+
}
924+
925+
@discardableResult
926+
func applyTrimEndInput(_ text: String) -> Bool {
927+
guard let seconds = parseHMS(text) else { return false }
928+
setTrimRange(
929+
startSeconds: trimStartSeconds,
930+
endSeconds: seconds
931+
)
932+
return true
933+
}
934+
917935
func beginSeek() {
918936
guard !isUserSeeking else { return }
919937
wasPlayingBeforeSeek = playback.isPlaying
@@ -1101,29 +1119,37 @@ final class AppState: ObservableObject {
11011119
}
11021120

11031121
/// Whether the export will composite + re-encode (vs. lossless passthrough
1104-
/// mux). Driven entirely by what rendered content is requested — never a
1105-
/// manual codec choice.
1122+
/// mux).
11061123
var exportWillReencode: Bool {
11071124
effectiveExportPreset != .originalTracksMOV
11081125
}
11091126

1110-
/// Read-only codec label for the dock indicator ("H.265" / "H.264").
1127+
/// Human-readable codec label for the active export mode.
11111128
var exportCodecLabel: String {
1112-
dominantSourceCodec.displayName
1129+
switch effectiveExportPreset {
1130+
case .maxQualityH264:
1131+
return "H.264"
1132+
case .maxQualityHEVC, .fastHEVC, .socialShareHEVC, .proxyHEVC:
1133+
return "H.265"
1134+
case .editFriendlyProRes:
1135+
return "ProRes"
1136+
case .originalTracksMOV:
1137+
return dominantSourceCodec.displayName
1138+
}
11131139
}
11141140

1115-
/// Caption beneath the export cluster, reflecting the automatic mode.
1141+
/// Caption beneath the export cluster.
11161142
var exportModeCaption: String {
11171143
exportWillReencode
1118-
? "Re-encoded · \(exportCodecLabel) · hardware accelerated"
1119-
: "Muxed · originals preserved · no re-encode"
1144+
? "Grid export · \(exportCodecLabel)"
1145+
: "Muxed · originals preserved"
11201146
}
11211147

11221148
var effectiveExportPreset: ExportPreset {
1123-
automaticExportPreset(
1149+
resolvedExportPreset(
1150+
requested: exportPreset,
11241151
overlayOptions: effectiveExportOverlayOptions,
1125-
cameraTrack: cameraTrack,
1126-
sourceCodec: dominantSourceCodec
1152+
cameraTrack: cameraTrack
11271153
)
11281154
}
11291155

@@ -1424,28 +1450,20 @@ final class AppState: ObservableObject {
14241450
)
14251451
}
14261452

1427-
/// Fully-automatic export preset selection. No manual codec choice: the app
1428-
/// muxes losslessly whenever no rendered content is required (the fastest,
1429-
/// non-destructive path), and otherwise composites and re-encodes, adopting
1430-
/// the source codec (H.264 footage → H.264, HEVC footage → HEVC) so it never
1431-
/// needlessly transcodes across codecs.
1432-
private func automaticExportPreset(
1453+
private func resolvedExportPreset(
1454+
requested preset: ExportPreset,
14331455
overlayOptions: ExportOverlayOptions,
1434-
cameraTrack: CameraTrack,
1435-
sourceCodec: VideoCodec
1456+
cameraTrack: CameraTrack
14361457
) -> ExportPreset {
1437-
let needsComposite = overlayOptions.telemetryHUD
1458+
guard preset == .originalTracksMOV else { return preset }
1459+
if overlayOptions.telemetryHUD
14381460
|| overlayOptions.routeMap
14391461
|| overlayOptions.privacyMask
14401462
|| overlayOptions.needsSidecars
1441-
|| !cameraTrack.isEmpty
1442-
guard needsComposite else { return .originalTracksMOV }
1443-
switch sourceCodec {
1444-
case .h264:
1445-
return .maxQualityH264
1446-
case .hevc, .other:
1463+
|| !cameraTrack.isEmpty {
14471464
return .maxQualityHEVC
14481465
}
1466+
return preset
14491467
}
14501468

14511469
private func exportRange(to chosenURL: URL, previewOnly: Bool, queued: Bool) {
@@ -1503,11 +1521,7 @@ final class AppState: ObservableObject {
15031521
let sets = selectedSetsForExport
15041522
let startDate = trimStartDate
15051523
let overlayOptions = effectiveExportOverlayOptions
1506-
let preset = automaticExportPreset(
1507-
overlayOptions: overlayOptions,
1508-
cameraTrack: cameraTrack,
1509-
sourceCodec: dominantSourceCodec
1510-
)
1524+
let preset = effectiveExportPreset
15111525
let endDate: Date
15121526
let endSeconds: Double
15131527
let rangeText: String
@@ -1539,6 +1553,19 @@ final class AppState: ObservableObject {
15391553
)
15401554
}
15411555

1556+
func setExportPreset(_ preset: ExportPreset) {
1557+
exportPreset = preset
1558+
switch preset {
1559+
case .originalTracksMOV:
1560+
exportOverlayOptions.telemetryHUD = false
1561+
exportOverlayOptions.routeMap = false
1562+
exportOverlayOptions.includeReport = false
1563+
exportOverlayOptions.includeScreenshot = false
1564+
case .maxQualityHEVC, .maxQualityH264, .fastHEVC, .socialShareHEVC, .proxyHEVC, .editFriendlyProRes:
1565+
break
1566+
}
1567+
}
1568+
15421569
private func buildHealthSummary(from sets: [ClipSet]) -> ExportHealthSummary {
15431570
TelemetryProcessor.buildHealthSummary(from: sets, layoutProfile: layoutProfile)
15441571
}

TeslaCam/ContentView.swift

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,10 @@ private struct TimelineExportCard: View {
781781
@ObservedObject var playbackUI: PlaybackUIState
782782
let timelineMarkers: [Date]
783783
let isSingleDayTimeline: Bool
784+
@State private var trimStartInput = ""
785+
@State private var trimEndInput = ""
786+
@FocusState private var focusedTrimField: TrimField?
787+
@State private var lastFocusedTrimField: TrimField?
784788

785789
var body: some View {
786790
let minDate = state.minDate ?? Date()
@@ -793,6 +797,25 @@ private struct TimelineExportCard: View {
793797
}
794798
.padding(TeslaCamTheme.Metrics.cardPaddingCompact)
795799
.teslaCamCard()
800+
.onAppear(perform: syncTrimInputs)
801+
.onChange(of: state.trimStartSeconds) {
802+
guard focusedTrimField != .start else { return }
803+
trimStartInput = formatHMS(state.trimStartSeconds)
804+
}
805+
.onChange(of: state.trimEndSeconds) {
806+
guard focusedTrimField != .end else { return }
807+
trimEndInput = formatHMS(state.trimEndSeconds)
808+
}
809+
.onChange(of: focusedTrimField) {
810+
let newValue = focusedTrimField
811+
if lastFocusedTrimField == .start, newValue != .start {
812+
commitTrimStartInput()
813+
}
814+
if lastFocusedTrimField == .end, newValue != .end {
815+
commitTrimEndInput()
816+
}
817+
lastFocusedTrimField = newValue
818+
}
796819
}
797820

798821
private var timelineDock: some View {
@@ -945,9 +968,8 @@ private struct TimelineExportCard: View {
945968
.frame(height: TeslaCamTheme.Metrics.compactControlHeight)
946969

947970
HStack(spacing: TeslaCamTheme.Spacing.s) {
948-
// Opt-in engraving. Default off keeps the fast, lossless muxed copy;
949-
// turning it on burns the telemetry HUD into the file, which forces a
950-
// composite re-encode (the caption to the right reflects the switch).
971+
// Telemetry burn-in remains optional. The base export stays a regular
972+
// encoded grid unless the user explicitly asks for original tracks.
951973
Toggle(isOn: $state.exportOverlayOptions.telemetryHUD) {
952974
Text("Engrave telemetry")
953975
.font(TeslaCamTheme.Typography.label)
@@ -957,7 +979,7 @@ private struct TimelineExportCard: View {
957979
.tint(TeslaCamTheme.Colors.accent)
958980
.fixedSize()
959981
.accessibilityIdentifier("engrave-telemetry-toggle")
960-
.accessibilityHint("Burns the telemetry HUD into the exported video. Off keeps a lossless, faster muxed copy.")
982+
.accessibilityHint("Burns the telemetry HUD into the exported video.")
961983

962984
Spacer(minLength: 0)
963985

@@ -990,17 +1012,17 @@ private struct TimelineExportCard: View {
9901012

9911013
private func controlTopRow(timeWidth: CGFloat, exportWidth: CGFloat) -> some View {
9921014
HStack(spacing: TeslaCamTheme.Spacing.s) {
993-
macTimeField(formatHMS(playbackUI.currentSeconds), alignment: .leading, width: timeWidth)
1015+
trimInputField("In", text: $trimStartInput, field: .start, width: timeWidth)
9941016

9951017
transportButtonCluster
9961018

997-
macTimeField(formatHMS(state.totalDuration), alignment: .trailing, width: timeWidth)
1019+
trimInputField("Out", text: $trimEndInput, field: .end, width: timeWidth)
9981020

9991021
inOutCluster
10001022

10011023
Spacer(minLength: 0)
10021024

1003-
codecField
1025+
codecPickerField
10041026

10051027
Button {
10061028
state.exportRange()
@@ -1015,35 +1037,71 @@ private struct TimelineExportCard: View {
10151037
}
10161038
}
10171039

1018-
private func macTimeField(_ value: String, alignment: Alignment, width: CGFloat = 118) -> some View {
1040+
private func trimInputField(_ title: String, text: Binding<String>, field: TrimField, width: CGFloat = 118) -> some View {
10191041
HStack(spacing: TeslaCamTheme.Spacing.tightGap) {
1020-
Image(systemName: "timer")
1042+
Text(title.uppercased())
10211043
.font(TeslaCamTheme.Typography.label)
1022-
Text(value)
1044+
.foregroundStyle(TeslaCamTheme.Colors.textTertiary)
1045+
TextField(title, text: text)
1046+
.textFieldStyle(.plain)
10231047
.font(TeslaCamTheme.Typography.monoDetail)
1048+
.foregroundStyle(TeslaCamTheme.Colors.textSecondary)
1049+
.multilineTextAlignment(.leading)
1050+
.focused($focusedTrimField, equals: field)
1051+
.onSubmit {
1052+
switch field {
1053+
case .start:
1054+
commitTrimStartInput()
1055+
case .end:
1056+
commitTrimEndInput()
1057+
}
1058+
}
10241059
.lineLimit(1)
10251060
.minimumScaleFactor(0.72)
10261061
}
1027-
.foregroundStyle(TeslaCamTheme.Colors.textSecondary)
10281062
.padding(.horizontal, TeslaCamTheme.Spacing.s)
1029-
.frame(width: width, height: TeslaCamTheme.Metrics.compactControlHeight, alignment: alignment)
1063+
.frame(width: width, height: TeslaCamTheme.Metrics.compactControlHeight, alignment: .leading)
10301064
.teslaCamCard(fill: TeslaCamTheme.Colors.surface, radius: TeslaCamTheme.Metrics.controlCorner)
10311065
}
10321066

1033-
// Read-only codec indicator. The export codec is fully automatic — adopted
1034-
// from the source footage (H.264 vs H.265) — so this is informational, not a
1035-
// menu. A fixed-width segment, so it can never overflow into the timeline the
1036-
// way the old PRESET dropdown did.
1037-
private var codecField: some View {
1067+
private var codecPickerField: some View {
10381068
HStack(spacing: TeslaCamTheme.Spacing.tightGap) {
10391069
Text("Codec".uppercased())
10401070
.font(TeslaCamTheme.Typography.label)
10411071
.foregroundStyle(TeslaCamTheme.Colors.textTertiary)
10421072

1043-
CodecSegmentIndicator(activeCodec: state.dominantSourceCodec)
1073+
Picker("", selection: exportPresetBinding) {
1074+
Text("H.265").tag(ExportPreset.maxQualityHEVC)
1075+
Text("H.264").tag(ExportPreset.maxQualityH264)
1076+
}
1077+
.labelsHidden()
1078+
.pickerStyle(.segmented)
1079+
.frame(width: 132)
10441080
}
10451081
}
10461082

1083+
private var exportPresetBinding: Binding<ExportPreset> {
1084+
Binding(
1085+
get: { state.exportPreset == .maxQualityH264 ? .maxQualityH264 : .maxQualityHEVC },
1086+
set: { state.setExportPreset($0) }
1087+
)
1088+
}
1089+
1090+
private func syncTrimInputs() {
1091+
trimStartInput = formatHMS(state.trimStartSeconds)
1092+
trimEndInput = formatHMS(state.trimEndSeconds)
1093+
}
1094+
1095+
private func commitTrimStartInput() {
1096+
_ = state.applyTrimStartInput(trimStartInput)
1097+
trimStartInput = formatHMS(state.trimStartSeconds)
1098+
}
1099+
1100+
private func commitTrimEndInput() {
1101+
_ = state.applyTrimEndInput(trimEndInput)
1102+
trimEndInput = formatHMS(state.trimEndSeconds)
1103+
}
1104+
10471105
private var playbackSecondsBinding: Binding<Double> {
10481106
Binding(
10491107
get: { playbackUI.currentSeconds },
@@ -1100,6 +1158,11 @@ private struct TimelineExportCard: View {
11001158

11011159
return parts.joined(separator: "")
11021160
}
1161+
1162+
private enum TrimField: Hashable {
1163+
case start
1164+
case end
1165+
}
11031166
}
11041167

11051168
private struct ClipInformationPanel: View {

TeslaCam/Utils.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ func formatHMS(_ seconds: Double) -> String {
4646
return String(format: "%02d:%02d:%02d", h, m, s)
4747
}
4848

49+
func parseHMS(_ text: String) -> Double? {
50+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
51+
guard !trimmed.isEmpty else { return nil }
52+
53+
let rawParts = trimmed.split(separator: ":", omittingEmptySubsequences: false)
54+
guard (1...3).contains(rawParts.count) else { return nil }
55+
56+
let parts = rawParts.compactMap { Int($0) }
57+
guard parts.count == rawParts.count, parts.allSatisfy({ $0 >= 0 }) else { return nil }
58+
59+
switch parts.count {
60+
case 1:
61+
return Double(parts[0])
62+
case 2:
63+
return Double(parts[0] * 60 + parts[1])
64+
case 3:
65+
return Double(parts[0] * 3600 + parts[1] * 60 + parts[2])
66+
default:
67+
return nil
68+
}
69+
}
70+
4971
func floorToMinute(_ date: Date) -> Date {
5072
let calendar = Calendar.current
5173
let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)

0 commit comments

Comments
 (0)