Skip to content
Merged
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
24 changes: 17 additions & 7 deletions Sources/Cutmark/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,9 @@ final class AppModel {
let doc: SessionDoc
let number: Int
let cam: URL?
let camStart: Date?
let camStart: CameraRecorder.Start?
let screen: URL?
let screenStart: Date?
let screenStart: ScreenRecorder.Start?
let screenDisplayID: CGDirectDisplayID?
let script: String?
let hook: String?
Expand Down Expand Up @@ -731,13 +731,20 @@ final class AppModel {
async let screenStarted = screen.startRecording(to: screenURL, micID: micID)
(cameraStart, screenStart) = try await (cameraStarted, screenStarted)
}
if !RecordingRecovery.started(in: doc.url, number: n, camera: cameraStart?.date, screen: screenStart?.date) {
let cameraQuality: CaptureTimingQuality? = cameraStart.map {
$0.timing == .videoFrame ? .videoFrame : .callback
}
if !RecordingRecovery.started(in: doc.url, number: n,
camera: cameraStart?.date, screen: screenStart?.date,
cameraHostTime: cameraStart?.hostTime, screenHostTime: screenStart?.hostTime,
cameraTimingQuality: cameraQuality,
screenTimingQuality: screenStart == nil ? nil : .videoFrame) {
// The original claim is still on disk. The take remains recoverable, but a crash may
// lose the exact sub-second camera/screen offset, so say so without stopping the take.
self.error = "Cutmark is recording, but it could not save exact crash-recovery timing for this take."
}
pending = Pending(doc: doc, number: n, cam: camURL, camStart: cameraStart?.date,
screen: screenURL, screenStart: screenStart?.date,
pending = Pending(doc: doc, number: n, cam: camURL, camStart: cameraStart,
screen: screenURL, screenStart: screenStart,
screenDisplayID: screenStart?.displayID,
script: script, hook: hook, shot: shotID)
recordingMarkerCount = 0
Expand Down Expand Up @@ -813,11 +820,14 @@ final class AppModel {
var takes: [Take] = []
if let c = p.cam, let cs = p.camStart {
takes.append(Take(number: p.number, kind: .camera, file: c.lastPathComponent,
startedAt: cs, duration: await Self.duration(c), script: p.script, hook: p.hook, shot: p.shot))
startedAt: cs.date, duration: await Self.duration(c), script: p.script, hook: p.hook, shot: p.shot,
captureHostTime: cs.hostTime,
timingQuality: cs.timing == .videoFrame ? .videoFrame : .callback))
}
if let s = p.screen, let ss = p.screenStart {
takes.append(Take(number: p.number, kind: .screen, file: s.lastPathComponent,
startedAt: ss, duration: await Self.duration(s), script: p.script, hook: p.hook, shot: p.shot))
startedAt: ss.date, duration: await Self.duration(s), script: p.script, hook: p.hook, shot: p.shot,
captureHostTime: ss.hostTime, timingQuality: .videoFrame))
}
let recorded = p.doc.addCutmark(takes)
if recorded {
Expand Down
25 changes: 25 additions & 0 deletions Sources/Cutmark/CaptureSync.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// The provenance of a take's start timestamp. A callback is not a media-frame timestamp.
enum CaptureTimingQuality: String, Codable, Sendable {
case videoFrame
case callback
case unknown
}

enum CaptureSync {
/// Seconds after the camera's first video frame that the screen's first accepted frame began.
/// Both timestamps must be on the host clock and from the same numbered recording.
/// This does not establish sample-accurate audio alignment or compensate for later drift.
static func screenMinusCameraSeconds(camera: Take?, screen: Take?) -> Double? {
guard let camera, let screen,
camera.kind == .camera, screen.kind == .screen,
camera.number == screen.number,
camera.timingQuality == .videoFrame, screen.timingQuality == .videoFrame,
let c = camera.captureHostTime, let s = screen.captureHostTime,
c.isFinite, s.isFinite, c > 0, s > 0 else { return nil }
let offset = s - c
// Seconds apart, not minutes: far-apart host timestamps cannot describe one paired take.
return offset.isFinite && abs(offset) <= 15 ? offset : nil
}
}
87 changes: 84 additions & 3 deletions Sources/Cutmark/Library.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,83 @@ struct Take: Codable, Identifiable, Hashable {
var script: String? // variant read in this take; nil = main script
var hook: String? // the hook (from hooks.json) that opened the script in this take
var shot: String? // the storyboard shot (its id) this take films
/// Seconds on CMClockGetHostTimeClock, not UNIX time. Comparable only within one capture.
var captureHostTime: Double? = nil
/// Nil means an older take with unknown timing accuracy; never infer a frame timestamp.
var timingQuality: CaptureTimingQuality? = nil
var id: String { file }

init(number: Int, kind: TakeKind, file: String, startedAt: Date, duration: Double? = nil,
keeper: Bool = false, name: String? = nil, script: String? = nil, hook: String? = nil,
shot: String? = nil, captureHostTime: Double? = nil, timingQuality: CaptureTimingQuality? = nil) {
self.number = number
self.kind = kind
self.file = file
self.startedAt = startedAt
self.duration = duration
self.keeper = keeper
self.name = name
self.script = script
self.hook = hook
self.shot = shot
self.captureHostTime = captureHostTime
self.timingQuality = timingQuality
}

private enum CodingKeys: String, CodingKey {
case number, kind, file, startedAt, duration, keeper, name, script, hook, shot
case captureHostTime, timingQuality
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
number = try c.decode(Int.self, forKey: .number)
kind = try c.decode(TakeKind.self, forKey: .kind)
file = try c.decode(String.self, forKey: .file)
if let dateString = try? c.decode(String.self, forKey: .startedAt) {
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let withoutFraction = ISO8601DateFormatter()
withoutFraction.formatOptions = [.withInternetDateTime]
guard let parsed = withFraction.date(from: dateString) ?? withoutFraction.date(from: dateString) else {
throw DecodingError.dataCorruptedError(forKey: .startedAt, in: c,
debugDescription: "Invalid take start date")
}
startedAt = parsed
} else {
// JSONDecoder() callers (e.g. storyboard) use the original numeric Date representation.
// Preserve that legacy decoding while session.json uses ISO8601 strings.
startedAt = try c.decode(Date.self, forKey: .startedAt)
}
duration = try c.decodeIfPresent(Double.self, forKey: .duration)
keeper = try c.decodeIfPresent(Bool.self, forKey: .keeper) ?? false
name = try c.decodeIfPresent(String.self, forKey: .name)
script = try c.decodeIfPresent(String.self, forKey: .script)
hook = try c.decodeIfPresent(String.self, forKey: .hook)
shot = try c.decodeIfPresent(String.self, forKey: .shot)
captureHostTime = try c.decodeIfPresent(Double.self, forKey: .captureHostTime)
// Missing/unknown future quality remains absent instead of rewriting legacy takes.
timingQuality = try? c.decodeIfPresent(CaptureTimingQuality.self, forKey: .timingQuality)
}

func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(number, forKey: .number)
try c.encode(kind, forKey: .kind)
try c.encode(file, forKey: .file)
// Only take starts use fractional ISO8601: createdAt and session IDs retain their legacy encoding.
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
try c.encode(formatter.string(from: startedAt), forKey: .startedAt)
try c.encodeIfPresent(duration, forKey: .duration)
try c.encode(keeper, forKey: .keeper)
try c.encodeIfPresent(name, forKey: .name)
try c.encodeIfPresent(script, forKey: .script)
try c.encodeIfPresent(hook, forKey: .hook)
try c.encodeIfPresent(shot, forKey: .shot)
try c.encodeIfPresent(captureHostTime, forKey: .captureHostTime)
try c.encodeIfPresent(timingQuality, forKey: .timingQuality)
}
}

struct SessionMeta: Codable, Equatable {
Expand Down Expand Up @@ -502,16 +578,21 @@ final class SessionDoc {
if meta.takes.isEmpty {
s += "_No takes yet._\n"
} else {
s += "Camera files carry the mic audio. Screen files carry the mic too, for syncing by waveform. "
s += "`screen offset` is how many seconds after the camera file the screen file starts.\n\n"
s += "Camera files carry the mic audio. Screen files carry the mic too, for checking sync by waveform. "
s += "`screen offset` is the measured difference between the first camera and screen video frames. "
s += "It is not an audio-sync guarantee; older recordings and callback-based starts are unverified.\n\n"
s += "| Take | Name | Keeper | Camera | Screen | Length | Screen offset |\n|---|---|---|---|---|---|---|\n"
for g in takeGroups.reversed() {
let cam = g.takes.first { $0.kind == .camera }
let scr = g.takes.first { $0.kind == .screen }
let len = (cam ?? scr)?.duration.map(Self.clock) ?? "?"
var offset = "–"
if let c = cam, let sc = scr {
offset = String(format: "%+.2fs", sc.startedAt.timeIntervalSince(c.startedAt))
if let precise = CaptureSync.screenMinusCameraSeconds(camera: c, screen: sc) {
offset = String(format: "%+.3fs", precise)
} else {
offset = "Unverified"
}
}
let name = g.takes.first?.name ?? ""
s += "| \(g.number) | \(name) | \(g.takes.first?.keeper == true ? "★" : "") | \(cam.map { "`\($0.file)`" } ?? "–") | \(scr.map { "`\($0.file)`" } ?? "–") | \(len) | \(offset) |\n"
Expand Down
22 changes: 19 additions & 3 deletions Sources/Cutmark/RecordingRecovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ enum RecordingRecovery {
var script: String?
var hook: String?
var shot: String?
/// Optional monotonic host-clock media starts. Old claims have only wall dates.
var cameraHostTime: Double? = nil
var screenHostTime: Double? = nil
var cameraTimingQuality: CaptureTimingQuality? = nil
var screenTimingQuality: CaptureTimingQuality? = nil
}

static func url(in session: URL, number: Int) -> URL {
Expand Down Expand Up @@ -72,10 +77,17 @@ enum RecordingRecovery {
}

@discardableResult
static func started(in session: URL, number: Int, camera: Date?, screen: Date?) -> Bool {
static func started(in session: URL, number: Int, camera: Date?, screen: Date?,
cameraHostTime: Double? = nil, screenHostTime: Double? = nil,
cameraTimingQuality: CaptureTimingQuality? = nil,
screenTimingQuality: CaptureTimingQuality? = nil) -> Bool {
guard var journal = read(session, number: number) else { return false }
journal.cameraStartedAt = camera
journal.screenStartedAt = screen
journal.cameraHostTime = cameraHostTime?.isFinite == true ? cameraHostTime : nil
journal.screenHostTime = screenHostTime?.isFinite == true ? screenHostTime : nil
journal.cameraTimingQuality = cameraTimingQuality
journal.screenTimingQuality = screenTimingQuality
return write(journal, in: session)
}

Expand Down Expand Up @@ -142,15 +154,19 @@ enum RecordingRecovery {
let cameraStart = journal.cameraStartedAt ?? created(camera) ?? journal.requestedAt
if let cameraDuration = await duration(camera), cameraDuration > 0 {
takes.append(Take(number: journal.number, kind: .camera, file: cameraName, startedAt: cameraStart,
duration: cameraDuration, script: journal.script, hook: journal.hook, shot: journal.shot))
duration: cameraDuration, script: journal.script, hook: journal.hook, shot: journal.shot,
captureHostTime: journal.cameraHostTime,
timingQuality: journal.cameraTimingQuality))
}
}
if let screenName = journal.screen {
let screen = session.appending(path: screenName)
if let screenDuration = await duration(screen), screenDuration > 0 {
takes.append(Take(number: journal.number, kind: .screen, file: screenName,
startedAt: journal.screenStartedAt ?? created(screen) ?? journal.requestedAt,
duration: screenDuration, script: journal.script, hook: journal.hook, shot: journal.shot))
duration: screenDuration, script: journal.script, hook: journal.hook, shot: journal.shot,
captureHostTime: journal.screenHostTime,
timingQuality: journal.screenTimingQuality))
}
}
return takes
Expand Down
Loading
Loading