diff --git a/rem/DB.swift b/rem/DB.swift index 07a237a..f07d05f 100644 --- a/rem/DB.swift +++ b/rem/DB.swift @@ -29,10 +29,12 @@ class DatabaseManager { private let uniqueAppNames = Table("unique_application_names") let allText = VirtualTable("allText") + let chunksFramesView = View("chunks_frames_view") private let id = Expression("id") private let offsetIndex = Expression("offsetIndex") private let chunkId = Expression("chunkId") + private let chunksFramesIndex = Expression("chunksFramesIndex") private let timestamp = Expression("timestamp") private let filePath = Expression("filePath") private let activeApplicationName = Expression("activeApplicationName") @@ -43,6 +45,7 @@ class DatabaseManager { private var currentChunkId: Int64 = 0 // Initialize with a default value private var lastFrameId: Int64 = 0 private var currentFrameOffset: Int64 = 0 + private var lastChunksFramesIndex: Int64 = 0 init() { if let savedir = RemFileManager.shared.getSaveDir() { @@ -54,6 +57,7 @@ class DatabaseManager { createTables() currentChunkId = getCurrentChunkId() lastFrameId = getLastFrameId() + lastChunksFramesIndex = getLastChunksFramesIndex() } func purge() { @@ -116,6 +120,26 @@ class DatabaseManager { // Text search try! db.run(allText.create(.FTS4(config), ifNotExists: true)) + + // Create chunksFramesView (ensures all frames have associated chunks) + let viewSQL = """ + CREATE VIEW IF NOT EXISTS chunks_frames_view AS + SELECT + ROW_NUMBER() OVER (ORDER BY vc.id, f.id) as chunksFramesIndex, + vc.id as chunkId, + vc.filePath, + f.id as frameId, + f.timestamp, + f.activeApplicationName, + f.offsetIndex + FROM + video_chunks vc + JOIN + frames f ON vc.id = f.chunkId + ORDER BY + vc.id, f.id; + """ + try! db.run(viewSQL) } private func createIndices() { @@ -124,6 +148,8 @@ class DatabaseManager { try db.run(frames.createIndex(chunkId, id, unique: false, ifNotExists: true)) try db.run(frames.createIndex(timestamp, ifNotExists: true)) + // For speeding up chunksFramesView + try db.run(videoChunks.createIndex(id, unique: true, ifNotExists: true)) // Additional indices can be added here as needed } catch { print("Failed to create indices: \(error)") @@ -132,8 +158,8 @@ class DatabaseManager { private func getCurrentChunkId() -> Int64 { do { - if let lastChunk = try db.pluck(videoChunks.order(id.desc)) { - return lastChunk[id] + 1 + if let lastFrame = try db.pluck(frames.order(id.desc)) { + return lastFrame[chunkId] + 1 } } catch { print("Error fetching last chunk ID: \(error)") @@ -147,17 +173,29 @@ class DatabaseManager { return lastFrame[id] } } catch { - print("Error fetching last chunk ID: \(error)") + print("Error fetching last frame ID: \(error)") + } + return 0 + } + + private func getLastChunksFramesIndex() -> Int64 { + do { + if let lastFrame = try db.pluck(chunksFramesView.order(chunksFramesIndex.desc)) { + return lastFrame[chunksFramesIndex] + } + } catch { + print("Error fetching last chunks frames Index: \(error)") } return 0 } // Insert a new video chunk and return its ID func startNewVideoChunk(filePath: String) -> Int64 { - let insert = videoChunks.insert(self.filePath <- filePath) + let insert = videoChunks.insert(self.id <- currentChunkId, self.filePath <- filePath) let id = try! db.run(insert) currentChunkId = id + 1 currentFrameOffset = 0 + lastChunksFramesIndex = getLastChunksFramesIndex() return id } @@ -208,6 +246,19 @@ class DatabaseManager { if let frame = try db.pluck(query) { return (frame[offsetIndex], frame[filePath]) } + } catch { + return nil + } + + return nil + } + + func getFrameByChunksFramesIndex(forIndex index: Int64) -> (offsetIndex: Int64, filePath: String)? { + do { + let query = chunksFramesView.filter(chunksFramesIndex == index).limit(1) + if let frame = try db.pluck(query) { + return (frame[offsetIndex], frame[filePath]) + } // let justFrameQuery = frames.filter(frames[id] === index).limit(1) // try! db.run(justFrameQuery.delete()) @@ -283,6 +334,10 @@ class DatabaseManager { return lastFrameId } + func getMaxChunksFramesIndex() -> Int64 { + return lastChunksFramesIndex + } + func getLastAccessibleFrame() -> Int64 { do { let query = frames.join(videoChunks, on: chunkId == videoChunks[id]).select(frames[id]).order(frames[id].desc).limit(1) @@ -387,6 +442,13 @@ class DatabaseManager { return extractFrame(from: videoURL, frameOffset: frameData.offsetIndex, maxSize: maxSize) } + func getImageByChunksFramesIndex(index: Int64, maxSize: CGSize? = nil) -> CGImage? { + guard let frameData = DatabaseManager.shared.getFrameByChunksFramesIndex(forIndex: index) else { return nil } + + let videoURL = URL(fileURLWithPath: frameData.filePath) + return extractFrame(from: videoURL, frameOffset: frameData.offsetIndex, maxSize: maxSize) + } + func getImages(filePath: String, frameOffsets: [Int64], maxSize: CGSize? = nil) -> AVAssetImageGenerator.Images { let videoURL = URL(fileURLWithPath: filePath) return extractFrames(from: videoURL, frameOffsets: frameOffsets, maxSize: maxSize) diff --git a/rem/TimelineView.swift b/rem/TimelineView.swift index 01dad8c..8ce0720 100644 --- a/rem/TimelineView.swift +++ b/rem/TimelineView.swift @@ -33,7 +33,7 @@ struct TimelineView: View { var body: some View { ZStack { let frame = NSScreen.main?.frame ?? NSRect.zero - let image = DatabaseManager.shared.getImage(index: viewModel.currentFrameIndex) + let image = DatabaseManager.shared.getImageByChunksFramesIndex(index: viewModel.currentFrameIndex) let nsImage = image.flatMap { NSImage(cgImage: $0, size: NSSize(width: $0.width, height: $0.height)) } CustomHostingControllerRepresentable( @@ -74,7 +74,7 @@ struct TimelineView: View { private func analyzeImage(index: Int64) { Task { - if let image = DatabaseManager.shared.getImage(index: index) { + if let image = DatabaseManager.shared.getImageByChunksFramesIndex(index: index) { let configuration = ImageAnalyzer.Configuration([.text]) do { let analysis = try await imageAnalyzer.analyze(image, orientation: CGImagePropertyOrientation.up, configuration: configuration) @@ -290,7 +290,7 @@ class TimelineViewModel: ObservableObject { private var indexUpdateThrottle = Throttler(delay: 0.05) init() { - let maxFrame = DatabaseManager.shared.getMaxFrame() + let maxFrame = DatabaseManager.shared.getMaxChunksFramesIndex() currentFrameIndex = maxFrame currentFrameContinuous = Double(maxFrame) } @@ -299,21 +299,21 @@ class TimelineViewModel: ObservableObject { // Logic to update the index based on the delta // This method will be called from AppDelegate let nextValue = currentFrameContinuous - delta * speedFactor - let maxValue = Double(DatabaseManager.shared.getMaxFrame()) + let maxValue = Double(DatabaseManager.shared.getMaxChunksFramesIndex()) let clampedValue = min(max(1, nextValue), maxValue) self.currentFrameContinuous = clampedValue self.updateIndexSafely() } func updateIndex(withIndex: Int64) { - let maxValue = Double(DatabaseManager.shared.getMaxFrame()) + let maxValue = Double(DatabaseManager.shared.getMaxChunksFramesIndex()) let clampedValue = min(max(1, Double(withIndex)), maxValue) self.currentFrameContinuous = clampedValue self.updateIndexSafely() } func setIndexToLatest() { - let maxFrame = DatabaseManager.shared.getMaxFrame() + let maxFrame = DatabaseManager.shared.getMaxChunksFramesIndex() DispatchQueue.main.async { self.currentFrameContinuous = Double(maxFrame) self.currentFrameIndex = maxFrame diff --git a/rem/remApp.swift b/rem/remApp.swift index 19bd9a4..2d8197b 100644 --- a/rem/remApp.swift +++ b/rem/remApp.swift @@ -196,7 +196,7 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { } let menu = NSMenu() let recordingTitle = self.isCapturing == .recording ? "Stop Remembering" : "Start Remembering" - let recordingSelector = self.isCapturing == .recording ? #selector(self.disableRecording) : #selector(self.enableRecording) + let recordingSelector = self.isCapturing == .recording ? #selector(self.userDisableRecording) : #selector(self.enableRecording) menu.addItem(NSMenuItem(title: recordingTitle, action: recordingSelector, keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Toggle Timeline", action: #selector(self.toggleTimeline), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Search", action: #selector(self.showSearchView), keyEquivalent: "")) @@ -225,7 +225,7 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { if isTimelineOpen() { closeTimelineView() } else { - let frame = DatabaseManager.shared.getMaxFrame() + let frame = DatabaseManager.shared.getMaxChunksFramesIndex() showTimelineView(with: frame) } } @@ -278,7 +278,7 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { if event.scrollingDeltaY < 0 && !isTimelineOpen() { // Check if scroll up DispatchQueue.main.async { [weak self] in - self?.showTimelineView(with: DatabaseManager.shared.getMaxFrame()) + self?.showTimelineView(with: DatabaseManager.shared.getMaxChunksFramesIndex()) } } } @@ -483,8 +483,6 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { if let savedir = RemFileManager.shared.getSaveDir() { let outputPath = savedir.appendingPathComponent("output-\(Date().timeIntervalSince1970).mp4").path - let _ = DatabaseManager.shared.startNewVideoChunk(filePath: outputPath) - // Setup the FFmpeg process for the chunk let ffmpegProcess = Process() let bundleURL = Bundle.main.bundleURL @@ -531,6 +529,17 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { // Close the pipe and handle the process completion ffmpegInputPipe.fileHandleForWriting.closeFile() + ffmpegProcess.waitUntilExit() + + // Check if FFmpeg process completed successfully + if ffmpegProcess.terminationStatus == 0 { + // Start new video chunk in database only if FFmpeg succeeds + let _ = DatabaseManager.shared.startNewVideoChunk(filePath: outputPath) + logger.info("Video successfully saved and registered.") + } else { + logger.error("FFmpeg failed to process video chunk.") + } + // Read FFmpeg's output and error let outputData = ffmpegOutputPipe.fileHandleForReading.readDataToEndOfFile() @@ -548,6 +557,9 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { } @objc func enableRecording() { + if isCapturing == .recording { + return + } isCapturing = .recording Task { @@ -560,6 +572,12 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { logger.info("Screen capture paused") } + @objc func userDisableRecording() { + wasRecordingBeforeSearchView = false + wasRecordingBeforeTimelineView = false + disableRecording() + } + @objc func disableRecording() { if isCapturing != .recording { return @@ -645,8 +663,9 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { } @objc func showTimelineView(with index: Int64) { - wasRecordingBeforeTimelineView = (isCapturing == .recording) + wasRecordingBeforeTimelineView = (isCapturing == .recording) || wasRecordingBeforeSearchView // handle going from search to TL disableRecording() + wasRecordingBeforeSearchView = false closeSearchView() if timelineViewWindow == nil { let screenRect = NSScreen.main?.frame ?? NSRect.zero @@ -710,8 +729,9 @@ func drawStatusBarIcon(rect: CGRect) -> Bool { } @objc func showSearchView() { - wasRecordingBeforeSearchView = (isCapturing == .recording) + wasRecordingBeforeSearchView = (isCapturing == .recording) || wasRecordingBeforeTimelineView disableRecording() + wasRecordingBeforeTimelineView = false closeTimelineView() // Ensure that the search view window is created and shown if searchViewWindow == nil {