From 01bbc11a713d4b45bcb8419ad643c4a0d79f680f Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Fri, 23 Oct 2026 00:34:25 -0700 Subject: [PATCH] Recording: preserve measured paired capture start times Persist provenance-gated host-clock video starts in paired takes and crash-recovery journals. Preserve subsecond take dates without changing legacy session IDs; both Swift and MCP manifests mark uncertain offsets unverified. Cover legacy decoding, callback starts and signed offsets, and fix a Jobs fixture readiness race. --- Sources/Cutmark/AppModel.swift | 24 ++-- Sources/Cutmark/CaptureSync.swift | 25 ++++ Sources/Cutmark/Library.swift | 87 ++++++++++++- Sources/Cutmark/RecordingRecovery.swift | 22 +++- Tests/CutmarkTests/CaptureSyncTests.swift | 118 ++++++++++++++++++ Tests/CutmarkTests/JobsTests.swift | 9 +- .../CutmarkTests/RecordingRecoveryTests.swift | 63 ++++++++++ mcp/cutmark_mcp.py | 35 ++++-- mcp/tests/test_capture_sync.py | 80 ++++++++++++ 9 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 Sources/Cutmark/CaptureSync.swift create mode 100644 Tests/CutmarkTests/CaptureSyncTests.swift create mode 100644 mcp/tests/test_capture_sync.py diff --git a/Sources/Cutmark/AppModel.swift b/Sources/Cutmark/AppModel.swift index 541de57..31418dc 100644 --- a/Sources/Cutmark/AppModel.swift +++ b/Sources/Cutmark/AppModel.swift @@ -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? @@ -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 @@ -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 { diff --git a/Sources/Cutmark/CaptureSync.swift b/Sources/Cutmark/CaptureSync.swift new file mode 100644 index 0000000..1dd6eb3 --- /dev/null +++ b/Sources/Cutmark/CaptureSync.swift @@ -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 + } +} diff --git a/Sources/Cutmark/Library.swift b/Sources/Cutmark/Library.swift index bc6cbd6..c25b7b6 100644 --- a/Sources/Cutmark/Library.swift +++ b/Sources/Cutmark/Library.swift @@ -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 { @@ -502,8 +578,9 @@ 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 } @@ -511,7 +588,11 @@ final class SessionDoc { 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" diff --git a/Sources/Cutmark/RecordingRecovery.swift b/Sources/Cutmark/RecordingRecovery.swift index 1cace37..7dee547 100644 --- a/Sources/Cutmark/RecordingRecovery.swift +++ b/Sources/Cutmark/RecordingRecovery.swift @@ -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 { @@ -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) } @@ -142,7 +154,9 @@ 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 { @@ -150,7 +164,9 @@ enum RecordingRecovery { 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 diff --git a/Tests/CutmarkTests/CaptureSyncTests.swift b/Tests/CutmarkTests/CaptureSyncTests.swift new file mode 100644 index 0000000..67bd8a8 --- /dev/null +++ b/Tests/CutmarkTests/CaptureSyncTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import Cutmark + +struct CaptureSyncTests { + private let referenceDate = Date(timeIntervalSince1970: 1_700_000_000.125) + + private func take(_ kind: TakeKind, number: Int = 8, + hostTime: Double? = 200, quality: CaptureTimingQuality? = .videoFrame) -> Take { + Take(number: number, kind: kind, file: "take-08-\(kind.rawValue).mov", + startedAt: referenceDate, duration: 6.5, + captureHostTime: hostTime, timingQuality: quality) + } + + @Test func measuredOffsetsKeepBothSignsAndSubsecondPrecision() throws { + let camera = take(.camera, hostTime: 1000.125) + let screenLate = take(.screen, hostTime: 1000.305) + let screenEarly = take(.screen, hostTime: 999.945) + #expect(abs(try #require(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: screenLate)) - 0.18) < 0.000_001) + #expect(abs(try #require(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: screenEarly)) + 0.18) < 0.000_001) + } + + @Test func missingUncertainAndUnrelatedSourcesNeverClaimSync() { + let camera = take(.camera) + let screen = take(.screen) + #expect(CaptureSync.screenMinusCameraSeconds(camera: nil, screen: screen) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: nil) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: screen, screen: camera) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: take(.screen, number: 9)) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: take(.camera, quality: .callback), screen: screen) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: take(.camera, quality: .unknown), screen: screen) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: take(.camera, quality: nil), screen: screen) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: take(.screen, quality: nil)) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: take(.screen, hostTime: 0)) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: take(.screen, hostTime: .infinity)) == nil) + #expect(CaptureSync.screenMinusCameraSeconds(camera: camera, screen: take(.screen, hostTime: 216)) == nil) + } + + @Test func takeRoundtripPreservesFrameStartFractionsAndHostClock() throws { + let camera = take(.camera, hostTime: 24000.123456) + var meta = SessionMeta(title: "A recording", createdAt: Date(timeIntervalSince1970: 1_700_000_000)) + meta.takes = [camera] + let data = try Store.encoder.encode(meta) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["createdAt"] as? String == "2023-11-14T22:13:20Z") + let records = try #require(json["takes"] as? [[String: Any]]) + #expect((records[0]["startedAt"] as? String)?.contains(".125Z") == true) + #expect(records[0]["captureHostTime"] as? Double == 24000.123456) + #expect(records[0]["timingQuality"] as? String == "videoFrame") + let decoded = try Store.decoder.decode(SessionMeta.self, from: data) + #expect(abs(try #require(decoded.takes.first).startedAt.timeIntervalSince(camera.startedAt)) < 0.001) + #expect(decoded.takes[0].captureHostTime == camera.captureHostTime) + #expect(decoded.takes[0].timingQuality == .videoFrame) + } + + @Test func legacySecondOnlyTakeRemainsReadableWithoutNewSyncFields() throws { + let json = """ + {"title":"Legacy","named":false,"createdAt":"2023-11-14T22:13:20Z","takes":[ + {"number":5,"kind":"camera","file":"take-05-camera.mov", + "startedAt":"2023-11-14T22:13:20Z","duration":8.0,"keeper":false}]} + """ + let old = try Store.decoder.decode(SessionMeta.self, from: Data(json.utf8)) + let take = try #require(old.takes.first) + #expect(take.captureHostTime == nil && take.timingQuality == nil) + let saved = try Store.encoder.encode(old) + let payload = try #require(JSONSerialization.jsonObject(with: saved) as? [String: Any]) + #expect(payload["createdAt"] as? String == "2023-11-14T22:13:20Z") + let takes = try #require(payload["takes"] as? [[String: Any]]) + #expect(takes[0]["captureHostTime"] == nil && takes[0]["timingQuality"] == nil) + #expect((takes[0]["startedAt"] as? String)?.hasPrefix("2023-11-14T22:13:20") == true) + #expect(try Store.decoder.decode(SessionMeta.self, from: saved).takes.count == 1) + } + + @Test func numericStartDatesRemainReadableByDefaultJSONDecoderCallers() throws { + // Storyboard and other in-memory callers historically encode Date as a reference-date number. + let json = #"{"number":1,"kind":"screen","file":"screen.mov","startedAt":12.5}"# + let decoded = try JSONDecoder().decode(Take.self, from: Data(json.utf8)) + #expect(decoded.startedAt == Date(timeIntervalSinceReferenceDate: 12.5)) + #expect(decoded.captureHostTime == nil && decoded.timingQuality == nil) + } + + @Test func legacySessionIDStaysIdenticalAfterRoundtripWithoutAnID() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-sync-session-\(UUID())") + let session = root.appending(path: "Project/Legacy") + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let original = """ + {"title":"Legacy","named":false,"createdAt":"2023-11-14T22:13:20Z","takes":[ + {"number":1,"kind":"camera","file":"take-01-camera.mov", + "startedAt":"2023-11-14T22:13:20Z"}]} + """ + let file = session.appending(path: "session.json") + try Data(original.utf8).write(to: file) + let before = try #require(Store.derivedID(session)) + let meta = try #require(Store.readMeta(session)) + try Store.encoder.encode(meta).write(to: file, options: .atomic) + let after = try #require(Store.derivedID(session)) + #expect(before == after) + #expect(Store.readMeta(session)?.id == nil) + } + + @Test @MainActor func sessionManifestShowsOnlyMeasuredPairedVideoOffset() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-sync-manifest-\(UUID())") + let session = root.appending(path: "Project/Session") + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let doc = SessionDoc(url: session) + doc.meta.takes = [take(.camera, hostTime: 1000.18), take(.screen, hostTime: 1000.0)] + #expect(doc.save()) + let measured = try String(contentsOf: session.appending(path: "SESSION.md"), encoding: .utf8) + #expect(measured.contains("-0.180s")) + doc.meta.takes[0].timingQuality = .callback + #expect(doc.save()) + let unverified = try String(contentsOf: session.appending(path: "SESSION.md"), encoding: .utf8) + #expect(unverified.contains("Unverified")) + #expect(!unverified.contains("-0.180s")) + } +} diff --git a/Tests/CutmarkTests/JobsTests.swift b/Tests/CutmarkTests/JobsTests.swift index d746f78..2426856 100644 --- a/Tests/CutmarkTests/JobsTests.swift +++ b/Tests/CutmarkTests/JobsTests.swift @@ -292,10 +292,13 @@ extension ChatRunTests { #expect(!told("job-1")) // The user stops a run; a job that ends then waits too. chat.send("Make the cut", title: "T", onStage: nil) - await until { runs == 1 } + // The fake writes its run counter before draining stdin to ask-1.txt. Under the full + // concurrent suite, `runs == 1` can precede the complete captured ask. + await until { runs == 1 && ask(1)?.hasSuffix("Make the cut") == true } // His message carries the end that waited since the launch. - #expect(ask(1)?.hasPrefix("[Cutmark] Job job-1 ended") == true) - #expect(ask(1)?.hasSuffix("Make the cut") == true) + let firstAsk = try #require(ask(1)) + #expect(firstAsk.hasPrefix("[Cutmark] Job job-1 ended")) + #expect(firstAsk.hasSuffix("Make the cut")) #expect(told("job-1")) chat.stop() await until { !chat.running } diff --git a/Tests/CutmarkTests/RecordingRecoveryTests.swift b/Tests/CutmarkTests/RecordingRecoveryTests.swift index 634c540..f0afee3 100644 --- a/Tests/CutmarkTests/RecordingRecoveryTests.swift +++ b/Tests/CutmarkTests/RecordingRecoveryTests.swift @@ -26,6 +26,69 @@ struct RecordingRecoveryTests { #expect(abs(try #require(after.screenStartedAt).timeIntervalSince(screenStart)) < 0.001) } + @Test func recoveredPairedFramesRetainHostClockAndSignedOffset() async throws { + let s = try session() + defer { try? FileManager.default.removeItem(at: s.deletingLastPathComponent().deletingLastPathComponent()) } + let camera = s.appending(path: "take-09-camera.mov") + let screen = s.appending(path: "take-09-screen.mov") + #expect(RecordingRecovery.begin(in: s, number: 9, camera: camera, screen: screen, + script: nil, hook: nil, shot: nil)) + let started = Date(timeIntervalSince1970: 1_700_000_000.42) + #expect(RecordingRecovery.started(in: s, number: 9, + camera: started, screen: started.addingTimeInterval(-0.180), + cameraHostTime: 1800.630, screenHostTime: 1800.450, + cameraTimingQuality: .videoFrame, screenTimingQuality: .videoFrame)) + let journal = try #require(RecordingRecovery.read(s, number: 9)) + #expect(journal.cameraHostTime == 1800.630 && journal.screenHostTime == 1800.450) + let recovered = await RecordingRecovery.recoverableTakes( + in: s, journal: journal, meta: SessionMeta(title: "x", createdAt: Date()) + ) { _ in 10 } + #expect(recovered.count == 2) + #expect(recovered.allSatisfy { $0.number == 9 && $0.timingQuality == .videoFrame }) + let offset = CaptureSync.screenMinusCameraSeconds(camera: recovered[0], screen: recovered[1]) + #expect(abs(try #require(offset) + 0.18) < 0.000_001) + #expect(abs(recovered[1].startedAt.timeIntervalSince(recovered[0].startedAt) + 0.18) < 0.001) + } + + @Test func legacyJournalWithoutHostClockCannotAssertMeasuredSync() async throws { + let s = try session() + defer { try? FileManager.default.removeItem(at: s.deletingLastPathComponent().deletingLastPathComponent()) } + // secondsSince1970 was the recovery journal's original date strategy. + let legacy = """ + {"number":4,"camera":"take-04-camera.mov","screen":"take-04-screen.mov", + "requestedAt":1700000000,"cameraStartedAt":1700000000.10,"screenStartedAt":1700000000.24} + """ + try Data(legacy.utf8).write(to: RecordingRecovery.url(in: s, number: 4)) + let journal = try #require(RecordingRecovery.read(s, number: 4)) + #expect(journal.cameraHostTime == nil && journal.screenHostTime == nil) + let recovered = await RecordingRecovery.recoverableTakes( + in: s, journal: journal, meta: SessionMeta(title: "x", createdAt: Date()) + ) { _ in 4 } + #expect(recovered.count == 2) + #expect(recovered.allSatisfy { $0.timingQuality == nil && $0.captureHostTime == nil }) + #expect(CaptureSync.screenMinusCameraSeconds(camera: recovered[0], screen: recovered[1]) == nil) + } + + @Test func callbackQualitySurvivesRecoveryWithoutBecomingVideoFrameSync() async throws { + let s = try session() + defer { try? FileManager.default.removeItem(at: s.deletingLastPathComponent().deletingLastPathComponent()) } + let camera = s.appending(path: "take-05-camera.mov") + let screen = s.appending(path: "take-05-screen.mov") + #expect(RecordingRecovery.begin(in: s, number: 5, camera: camera, screen: screen, + script: nil, hook: nil, shot: nil)) + #expect(RecordingRecovery.started(in: s, number: 5, + camera: Date(), screen: Date(), + cameraHostTime: 100, screenHostTime: 100.24, + cameraTimingQuality: .callback, screenTimingQuality: .videoFrame)) + let journal = try #require(RecordingRecovery.read(s, number: 5)) + let recovered = await RecordingRecovery.recoverableTakes( + in: s, journal: journal, meta: SessionMeta(title: "x", createdAt: Date()) + ) { _ in 4 } + #expect(recovered.first?.timingQuality == .callback) + #expect(recovered.last?.timingQuality == .videoFrame) + #expect(CaptureSync.screenMinusCameraSeconds(camera: recovered[0], screen: recovered[1]) == nil) + } + @Test func playableCameraAndScreenBecomeTheSameTake() async throws { let s = try session() defer { try? FileManager.default.removeItem(at: s.deletingLastPathComponent().deletingLastPathComponent()) } diff --git a/mcp/cutmark_mcp.py b/mcp/cutmark_mcp.py index 6cfbd2d..652878f 100644 --- a/mcp/cutmark_mcp.py +++ b/mcp/cutmark_mcp.py @@ -91,10 +91,27 @@ def now_iso_ms(): def parse_iso(s): - try: - return datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) - except Exception: + for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except (TypeError, ValueError): + pass + return None + + +def paired_video_start_offset(camera, screen): + """Screen minus camera first-video host seconds; never an audio-sync or legacy-date estimate.""" + if not camera or not screen or camera.get("number") != screen.get("number"): + return None + if camera.get("timingQuality") != "videoFrame" or screen.get("timingQuality") != "videoFrame": + return None + values = (camera.get("captureHostTime"), screen.get("captureHostTime")) + if any(type(v) not in (int, float) or not math.isfinite(v) or v <= 0 for v in values): return None + offset = values[1] - values[0] + # A new paired recording starts both recorders together (8s camera/5s screen start limits). + # Reject corrupt or cross-boot host timestamps rather than advertise a fictional alignment. + return offset if abs(offset) <= 15 else None def lib_parts(path, r=None): @@ -218,8 +235,9 @@ def write_meta(session, meta): if not takes: lines.append("_No takes yet._") else: - lines.append("Camera files carry the mic audio. Screen files carry the mic too, for syncing by waveform. " - "`screen offset` is how many seconds after the camera file the screen file starts.\n") + lines.append("Camera and screen may each carry microphone audio for waveform alignment. " + "`Screen offset` is the measured difference between first observed video frames, " + "not proof of audio sync or a rendered composite. Older and callback-timed starts are unverified.\n") lines.append("| Take | Name | Keeper | Camera | Screen | Length | Screen offset |\n|---|---|---|---|---|---|---|") for n in sorted({t["number"] for t in takes}): g = [t for t in takes if t["number"] == n] @@ -227,8 +245,11 @@ def write_meta(session, meta): scr = next((t for t in g if t["kind"] == "screen"), None) dur = (cam or scr or {}).get("duration") off = "–" - if cam and scr and parse_iso(cam["startedAt"]) and parse_iso(scr["startedAt"]): - off = "%+.2fs" % (parse_iso(scr["startedAt"]) - parse_iso(cam["startedAt"])).total_seconds() + if cam and scr: + off = "Unverified" + delta = paired_video_start_offset(cam, scr) + if delta is not None: + off = "%+.3fs" % delta lines.append("| %d | %s | %s | %s | %s | %s | %s |" % ( n, g[0].get("name") or "", "★" if g[0].get("keeper") else "", "`%s`" % cam["file"] if cam else "–", "`%s`" % scr["file"] if scr else "–", diff --git a/mcp/tests/test_capture_sync.py b/mcp/tests/test_capture_sync.py new file mode 100644 index 0000000..febbe01 --- /dev/null +++ b/mcp/tests/test_capture_sync.py @@ -0,0 +1,80 @@ +"""Capture timing is metadata, not a promise of composite or audio synchronization.""" +import json +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import cutmark_mcp as t # noqa: E402 + + +class CaptureSyncTests(unittest.TestCase): + def setUp(self): + self.top = tempfile.mkdtemp(prefix="cutmark-sync-") + os.environ["CUTMARK_ROOT"] = self.top + self.session = os.path.join(self.top, "Demo", "2026-10-23-paired") + os.makedirs(self.session) + + def tearDown(self): + os.environ.pop("CUTMARK_ROOT", None) + shutil.rmtree(self.top) + + @staticmethod + def take(kind, host, quality="videoFrame", number=1): + return {"number": number, "kind": kind, "file": "take-01-%s.mov" % kind, + "startedAt": "2026-10-23T07:01:02.123Z", "duration": 5.0, + "captureHostTime": host, "timingQuality": quality} + + def test_signed_screen_start_offsets_use_host_clock_not_rounded_dates(self): + camera = self.take("camera", 1000.25) + screen = self.take("screen", 1000.437) + self.assertAlmostEqual(t.paired_video_start_offset(camera, screen), 0.187) + camera["startedAt"] = screen["startedAt"] # Same wall-clock spelling is irrelevant. + self.assertAlmostEqual(t.paired_video_start_offset(camera, screen), 0.187) + screen["captureHostTime"] = 1000.08 + self.assertAlmostEqual(t.paired_video_start_offset(camera, screen), -0.17) + + def test_unknown_legacy_fallback_and_mismatched_takes_are_not_labeled_synced(self): + camera = self.take("camera", 101.0) + screen = self.take("screen", 101.2) + for key in ("captureHostTime", "timingQuality"): + old = dict(camera) + old.pop(key) + self.assertIsNone(t.paired_video_start_offset(old, screen)) + self.assertIsNone(t.paired_video_start_offset(camera, self.take("screen", 101.2, number=2))) + self.assertIsNone(t.paired_video_start_offset(camera, self.take("screen", 101.2, "callback"))) + self.assertIsNone(t.paired_video_start_offset(camera, None)) + self.assertIsNone(t.paired_video_start_offset(None, screen)) + + def test_nonfinite_crossboot_and_boolean_times_are_rejected(self): + camera = self.take("camera", 1000.0) + for invalid in (True, False, None, "1000.2", float("nan"), float("inf"), -1, 100.0): + self.assertIsNone(t.paired_video_start_offset(camera, self.take("screen", invalid))) + + def test_session_manifest_discloses_only_verifiable_frame_start_difference(self): + camera, screen = self.take("camera", 200.55), self.take("screen", 200.31) + meta = {"title": "Paired", "createdAt": "2026-10-23T07:00:00Z", "takes": [camera, screen]} + t.write_meta(self.session, meta) + with open(os.path.join(self.session, "SESSION.md")) as f: + summary = f.read() + self.assertIn("-0.240s", summary) + self.assertIn("not proof of audio sync", summary) + with open(os.path.join(self.session, "session.json")) as f: + persisted = json.load(f) + self.assertEqual(persisted["takes"], [camera, screen]) + meta["takes"][0].pop("captureHostTime") + t.write_meta(self.session, meta) + with open(os.path.join(self.session, "SESSION.md")) as f: + self.assertNotIn("-0.240s", f.read()) + + def test_iso_parser_reads_old_and_fractional_timestamp_formats(self): + old = t.parse_iso("2026-10-23T07:01:02Z") + precise = t.parse_iso("2026-10-23T07:01:02.123Z") + self.assertEqual((precise - old).total_seconds(), 0.123) + self.assertIsNone(t.parse_iso("broken")) + + +if __name__ == "__main__": + unittest.main()