dashcam 1.0.1-beta.2 → 1.0.1-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,17 @@ import { spawn } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
+ import { fileURLToPath } from 'url';
5
6
  import { logger } from './logger.js';
6
7
 
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
7
11
  // Use a fixed directory in the user's home directory for cross-process communication
8
12
  const PROCESS_DIR = path.join(os.homedir(), '.dashcam-cli');
9
13
  const PID_FILE = path.join(PROCESS_DIR, 'recording.pid');
10
14
  const STATUS_FILE = path.join(PROCESS_DIR, 'status.json');
15
+ const RESULT_FILE = path.join(PROCESS_DIR, 'upload-result.json');
11
16
 
12
17
  // Ensure process directory exists
13
18
  if (!fs.existsSync(PROCESS_DIR)) {
@@ -47,6 +52,36 @@ class ProcessManager {
47
52
  }
48
53
  }
49
54
 
55
+ writeUploadResult(result) {
56
+ try {
57
+ logger.info('Writing upload result to file', { path: RESULT_FILE, shareLink: result.shareLink });
58
+ fs.writeFileSync(RESULT_FILE, JSON.stringify({
59
+ ...result,
60
+ timestamp: Date.now()
61
+ }, null, 2));
62
+ logger.info('Successfully wrote upload result to file');
63
+ // Verify it was written
64
+ if (fs.existsSync(RESULT_FILE)) {
65
+ logger.info('Verified upload result file exists');
66
+ } else {
67
+ logger.error('Upload result file does not exist after write!');
68
+ }
69
+ } catch (error) {
70
+ logger.error('Failed to write upload result file', { error });
71
+ }
72
+ }
73
+
74
+ readUploadResult() {
75
+ try {
76
+ if (!fs.existsSync(RESULT_FILE)) return null;
77
+ const data = fs.readFileSync(RESULT_FILE, 'utf8');
78
+ return JSON.parse(data);
79
+ } catch (error) {
80
+ logger.error('Failed to read upload result file', { error });
81
+ return null;
82
+ }
83
+ }
84
+
50
85
  writePid(pid = process.pid) {
51
86
  try {
52
87
  fs.writeFileSync(PID_FILE, pid.toString());
@@ -80,7 +115,8 @@ class ProcessManager {
80
115
  const status = this.readStatus();
81
116
 
82
117
  if (!pid || !this.isProcessRunning(pid)) {
83
- this.cleanup();
118
+ // Clean up but preserve upload result in case the background process just finished uploading
119
+ this.cleanup({ preserveResult: true });
84
120
  return false;
85
121
  }
86
122
 
@@ -92,10 +128,12 @@ class ProcessManager {
92
128
  return this.readStatus();
93
129
  }
94
130
 
95
- cleanup() {
131
+ cleanup(options = {}) {
132
+ const { preserveResult = false } = options;
96
133
  try {
97
134
  if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
98
135
  if (fs.existsSync(STATUS_FILE)) fs.unlinkSync(STATUS_FILE);
136
+ if (!preserveResult && fs.existsSync(RESULT_FILE)) fs.unlinkSync(RESULT_FILE);
99
137
  } catch (error) {
100
138
  logger.error('Failed to cleanup process files', { error });
101
139
  }
@@ -149,8 +187,8 @@ class ProcessManager {
149
187
  hasApps: result.apps?.length > 0
150
188
  });
151
189
 
152
- // Cleanup process files
153
- this.cleanup();
190
+ // Cleanup process files but preserve upload result for stop command
191
+ this.cleanup({ preserveResult: true });
154
192
 
155
193
  return result;
156
194
  } catch (recorderError) {
@@ -168,7 +206,7 @@ class ProcessManager {
168
206
  logs: []
169
207
  };
170
208
 
171
- this.cleanup();
209
+ this.cleanup({ preserveResult: true });
172
210
  return result;
173
211
  }
174
212
  } else {
@@ -188,44 +226,68 @@ class ProcessManager {
188
226
  throw new Error('Recording already in progress');
189
227
  }
190
228
 
191
- try {
192
- // Import and call the recording function directly
193
- const { startRecording } = await import('./recorder.js');
194
-
195
- const recordingOptions = {
196
- fps: parseInt(options.fps) || 10,
197
- includeAudio: options.audio || false,
198
- customOutputPath: options.output || null
199
- };
200
-
201
- logger.info('Starting recording directly', { options: recordingOptions });
202
-
203
- const result = await startRecording(recordingOptions);
204
-
205
- // Write status to track the recording
206
- this.writePid(process.pid);
207
- this.writeStatus({
208
- isRecording: true,
209
- startTime: result.startTime, // Use actual recording start time from recorder
210
- options,
211
- pid: process.pid,
212
- outputPath: result.outputPath
213
- });
214
-
215
- logger.info('Recording started successfully', {
216
- outputPath: result.outputPath,
217
- startTime: result.startTime
218
- });
219
-
220
- return {
221
- pid: process.pid,
222
- outputPath: result.outputPath,
223
- startTime: result.startTime
224
- };
225
- } catch (error) {
226
- logger.error('Failed to start recording', { error });
227
- throw error;
229
+ // Always run in background mode by spawning a detached process
230
+ logger.info('Starting recording in background mode');
231
+
232
+ // Get the path to the CLI binary
233
+ const binPath = path.join(__dirname, '..', 'bin', 'dashcam-background.js');
234
+
235
+ logger.debug('Background process path', { binPath, exists: fs.existsSync(binPath) });
236
+
237
+ // Create log files for background process
238
+ const logDir = PROCESS_DIR;
239
+ const stdoutLog = path.join(logDir, 'background-stdout.log');
240
+ const stderrLog = path.join(logDir, 'background-stderr.log');
241
+
242
+ const stdoutFd = fs.openSync(stdoutLog, 'a');
243
+ const stderrFd = fs.openSync(stderrLog, 'a');
244
+
245
+ // Spawn a detached process that will handle the recording
246
+ const backgroundProcess = spawn(process.execPath, [
247
+ binPath,
248
+ JSON.stringify(options)
249
+ ], {
250
+ detached: true,
251
+ stdio: ['ignore', stdoutFd, stderrFd], // Log stdout and stderr
252
+ env: {
253
+ ...process.env,
254
+ DASHCAM_BACKGROUND: 'true'
255
+ }
256
+ });
257
+
258
+ // Close the file descriptors in the parent process
259
+ fs.closeSync(stdoutFd);
260
+ fs.closeSync(stderrFd);
261
+
262
+ // Get the background process PID before unreffing
263
+ const backgroundPid = backgroundProcess.pid;
264
+
265
+ // Allow the parent process to exit independently
266
+ backgroundProcess.unref();
267
+
268
+ // Wait a moment for the background process to initialize
269
+ await new Promise(resolve => setTimeout(resolve, 2000));
270
+
271
+ // Read the status file to get recording details
272
+ const status = this.readStatus();
273
+
274
+ if (!status || !status.isRecording) {
275
+ throw new Error('Background process failed to start recording');
228
276
  }
277
+
278
+ // Write PID file so other commands can find the background process
279
+ this.writePid(status.pid);
280
+
281
+ logger.info('Background recording process started', {
282
+ pid: status.pid,
283
+ outputPath: status.outputPath
284
+ });
285
+
286
+ return {
287
+ pid: status.pid,
288
+ outputPath: status.outputPath,
289
+ startTime: status.startTime
290
+ };
229
291
  }
230
292
 
231
293
  async gracefulExit() {
package/lib/recorder.js CHANGED
@@ -8,6 +8,47 @@ import path from 'path';
8
8
  import os from 'os';
9
9
  import fs from 'fs';
10
10
 
11
+ /**
12
+ * Fix/repair a video file by re-muxing it with proper container metadata
13
+ * This copies streams without re-encoding and ensures proper container finalization
14
+ */
15
+ async function fixVideoContainer(inputVideoPath, outputVideoPath) {
16
+ const logExit = logFunctionCall('fixVideoContainer', { inputVideoPath, outputVideoPath });
17
+
18
+ try {
19
+ const ffmpegPath = await getFfmpegPath();
20
+
21
+ logger.info('Re-muxing video to fix container metadata', {
22
+ input: inputVideoPath,
23
+ output: outputVideoPath
24
+ });
25
+
26
+ const args = [
27
+ '-i', inputVideoPath,
28
+ '-vcodec', 'copy', // Copy video stream without re-encoding
29
+ '-acodec', 'copy', // Copy audio stream without re-encoding
30
+ '-movflags', 'faststart', // Enable fast start for web playback
31
+ outputVideoPath,
32
+ '-y', // Overwrite output file
33
+ '-hide_banner'
34
+ ];
35
+
36
+ await execa(ffmpegPath, args);
37
+
38
+ logger.info('Successfully re-muxed video', {
39
+ outputPath: outputVideoPath,
40
+ outputSize: fs.existsSync(outputVideoPath) ? fs.statSync(outputVideoPath).size : 0
41
+ });
42
+
43
+ logExit();
44
+ return true;
45
+ } catch (error) {
46
+ logger.error('Failed to fix video container', { error: error.message });
47
+ logExit();
48
+ return false;
49
+ }
50
+ }
51
+
11
52
  /**
12
53
  * Dynamically detect the primary screen capture device for the current platform
13
54
  */
@@ -116,9 +157,8 @@ const PLATFORM_CONFIG = {
116
157
  audioInput: '0', // Default audio device if needed
117
158
  audioFormat: 'avfoundation',
118
159
  extraArgs: [
119
- '-video_size', '1920x1080', // Set explicit resolution
120
- '-pixel_format', 'uyvy422', // Use supported pixel format
121
- '-r', '30' // Set frame rate
160
+ '-capture_cursor', '1', // Capture mouse cursor
161
+ '-capture_mouse_clicks', '1' // Capture mouse clicks
122
162
  ]
123
163
  },
124
164
  win32: {
@@ -160,6 +200,7 @@ async function getPlatformArgs({ fps, includeAudio }) {
160
200
  });
161
201
 
162
202
  const args = [
203
+ '-thread_queue_size', '512', // Increase input buffer to prevent drops
163
204
  '-f', config.inputFormat
164
205
  ];
165
206
 
@@ -170,12 +211,14 @@ async function getPlatformArgs({ fps, includeAudio }) {
170
211
 
171
212
  args.push(
172
213
  '-framerate', fps.toString(),
214
+ '-probesize', '50M', // Increase probe size for better stream detection
173
215
  '-i', screenInput
174
216
  );
175
217
 
176
218
  // Add audio capture if enabled
177
219
  if (includeAudio) {
178
220
  args.push(
221
+ '-thread_queue_size', '512', // Increase audio buffer too
179
222
  '-f', config.audioFormat,
180
223
  '-i', config.audioInput
181
224
  );
@@ -278,15 +321,21 @@ export async function startRecording({
278
321
  // Construct FFmpeg command arguments
279
322
  const platformArgs = await getPlatformArgs({ fps, includeAudio });
280
323
  const outputArgs = [
281
- '-c:v', 'libvpx', // Use VP8 codec instead of VP9 for better compatibility
282
- '-b:v', '1M', // Set specific bitrate instead of variable
283
- // Remove explicit pixel format to let ffmpeg handle conversion automatically
324
+ '-c:v', 'libvpx-vp9', // Use VP9 codec for better quality and compression
325
+ '-quality', 'good', // Use 'good' quality preset (better than realtime, not as slow as best)
326
+ '-cpu-used', '4', // Faster encoding (0-8, higher = faster but lower quality)
327
+ '-deadline', 'good', // Good quality encoding mode
328
+ '-b:v', '2M', // Target bitrate
284
329
  '-r', fps.toString(), // Ensure output framerate matches input
285
- '-g', '30', // Keyframe every 30 frames
286
- // WebM options for more frequent disk writes
287
- '-f', 'webm', // Force WebM container format
288
- '-flush_packets', '1', // Flush packets immediately to disk
289
- '-max_muxing_queue_size', '1024' // Limit muxing queue to prevent delays
330
+ '-g', fps.toString(), // Keyframe interval = 1 second (every fps frames) - ensures frequent keyframes
331
+ '-force_key_frames', `expr:gte(t,n_forced*1)`, // Force keyframe every 1 second
332
+ // WebM options for more frequent disk writes and proper stream handling
333
+ '-f', 'webm', // Force WebM container format
334
+ '-flush_packets', '1', // Flush packets immediately to disk - critical for short recordings
335
+ '-fflags', '+genpts', // Generate presentation timestamps
336
+ '-avoid_negative_ts', 'make_zero', // Avoid negative timestamps
337
+ '-vsync', '1', // Ensure every frame is encoded (CFR - constant frame rate)
338
+ '-max_muxing_queue_size', '9999' // Large queue to prevent frame drops
290
339
  ];
291
340
 
292
341
  if (includeAudio) {
@@ -358,13 +407,11 @@ export async function startRecording({
358
407
  all: true, // Capture both stdout and stderr
359
408
  stdin: 'pipe' // Enable stdin for sending 'q' to stop recording
360
409
  });
361
-
362
- recordingStartTime = Date.now();
363
410
 
364
411
  logger.info('FFmpeg process spawned', {
365
412
  pid: currentRecording.pid,
366
413
  args: args.slice(-5), // Log last 5 args including output file
367
- tempFile
414
+ tempFile
368
415
  });
369
416
 
370
417
  // Check if temp file gets created within first few seconds
@@ -392,6 +439,14 @@ export async function startRecording({
392
439
  directory: path.dirname(outputPath)
393
440
  });
394
441
 
442
+ // Set recording start time AFTER log tracker is initialized
443
+ // This ensures the timeline starts when the tracker is ready to capture events
444
+ recordingStartTime = Date.now();
445
+ logger.info('Recording timeline started', {
446
+ recordingStartTime,
447
+ recordingStartTimeReadable: new Date(recordingStartTime).toISOString()
448
+ });
449
+
395
450
  if (currentRecording.all) {
396
451
  currentRecording.all.setEncoding('utf8');
397
452
  currentRecording.all.on('data', (data) => {
@@ -456,29 +511,41 @@ export async function stopRecording() {
456
511
  duration: recordingDuration,
457
512
  durationSeconds: (recordingDuration / 1000).toFixed(1)
458
513
  });
514
+
515
+ // Enforce minimum recording duration to prevent single-frame videos
516
+ const MIN_RECORDING_DURATION = 2000; // 2 seconds minimum
517
+ if (recordingDuration < MIN_RECORDING_DURATION) {
518
+ const waitTime = MIN_RECORDING_DURATION - recordingDuration;
519
+ logger.info(`Recording too short (${recordingDuration}ms), waiting ${waitTime}ms to ensure multiple frames`);
520
+ await new Promise(resolve => setTimeout(resolve, waitTime));
521
+ }
459
522
 
460
523
  try {
461
524
  // First try to gracefully stop FFmpeg by sending 'q'
462
525
  if (currentRecording && currentRecording.stdin) {
463
526
  logger.debug('Sending quit signal to FFmpeg...');
464
527
  currentRecording.stdin.write('q');
528
+ currentRecording.stdin.end(); // Close stdin to signal end
465
529
  }
466
530
 
467
- // Wait for FFmpeg to finish gracefully
531
+ // Wait longer for FFmpeg to finish gracefully - critical for VP9 encoding
532
+ // VP9 encoding needs time to flush buffers and finalize the container
468
533
  const gracefulTimeout = setTimeout(() => {
469
534
  if (currentRecording && !currentRecording.killed) {
535
+ logger.warn('FFmpeg did not exit gracefully after 8s, sending SIGTERM...');
470
536
  // If still running, try SIGTERM
471
537
  process.kill(currentRecording.pid, 'SIGTERM');
472
538
  }
473
- }, 2000);
539
+ }, 8000); // Increased to 8 seconds for VP9 finalization
474
540
 
475
- // Wait up to 5 seconds for SIGTERM to work
541
+ // Wait up to 15 seconds for SIGTERM to work
476
542
  const hardKillTimeout = setTimeout(() => {
477
543
  if (currentRecording && !currentRecording.killed) {
544
+ logger.error('FFmpeg still running after SIGTERM, using SIGKILL...');
478
545
  // If still not dead, use SIGKILL as last resort
479
546
  process.kill(currentRecording.pid, 'SIGKILL');
480
547
  }
481
- }, 5000);
548
+ }, 15000); // Increased to 15 seconds
482
549
 
483
550
  // Wait for the process to fully exit
484
551
  if (currentRecording) {
@@ -489,8 +556,10 @@ export async function stopRecording() {
489
556
  clearTimeout(gracefulTimeout);
490
557
  clearTimeout(hardKillTimeout);
491
558
 
492
- // Additional wait to ensure filesystem is synced - increased for reliability
493
- await new Promise(resolve => setTimeout(resolve, 3000));
559
+ // Additional wait to ensure filesystem is synced and encoder buffers are flushed
560
+ // This is especially important for VP9 which has larger encoding buffers
561
+ logger.debug('Waiting for filesystem sync and VP9 encoder finalization...');
562
+ await new Promise(resolve => setTimeout(resolve, 3000)); // Keep at 3 seconds after process exit
494
563
 
495
564
  // Read temp file path from disk (for cross-process access)
496
565
  let tempFile = currentTempFile; // Try in-memory first
@@ -538,21 +607,42 @@ export async function stopRecording() {
538
607
  path: tempFile
539
608
  });
540
609
 
541
- // Since WebM is already a valid streaming format, just copy the temp file
542
- // instead of trying to re-encode it which can hang
543
- logger.debug('Copying temp file to final output...');
610
+ // Since WebM is already a valid streaming format, re-mux it to ensure
611
+ // proper container metadata (duration, seekability, etc.)
612
+ logger.debug('Re-muxing temp file to fix container metadata...');
544
613
 
545
614
  try {
546
- fs.copyFileSync(tempFile, outputPath);
547
- logger.info('Successfully copied temp file to final output');
615
+ // First, create a temporary fixed version
616
+ const fixedTempFile = tempFile.replace('.webm', '-fixed.webm');
617
+
618
+ const fixSuccess = await fixVideoContainer(tempFile, fixedTempFile);
619
+
620
+ if (fixSuccess && fs.existsSync(fixedTempFile) && fs.statSync(fixedTempFile).size > 0) {
621
+ // Use the fixed version
622
+ logger.info('Using re-muxed version with proper container metadata');
623
+ fs.copyFileSync(fixedTempFile, outputPath);
624
+
625
+ // Clean up the fixed temp file
626
+ try {
627
+ fs.unlinkSync(fixedTempFile);
628
+ } catch (e) {
629
+ logger.debug('Failed to delete fixed temp file:', e);
630
+ }
631
+ } else {
632
+ // Fallback: just copy the original temp file
633
+ logger.warn('Re-muxing failed, using original file');
634
+ fs.copyFileSync(tempFile, outputPath);
635
+ }
636
+
637
+ logger.info('Successfully finalized recording to output');
548
638
 
549
639
  // Verify the final file exists and has content
550
640
  if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
551
- throw new Error('Final output file is empty or missing after copy');
641
+ throw new Error('Final output file is empty or missing after processing');
552
642
  }
553
643
 
554
644
  } catch (error) {
555
- logger.error('Failed to copy temp file:', error);
645
+ logger.error('Failed to process temp file:', error);
556
646
  throw new Error('Failed to finalize recording: ' + error.message);
557
647
  }
558
648
 
@@ -622,6 +712,12 @@ export async function stopRecording() {
622
712
  icons: appTrackingResults.icons, // Include application icons metadata
623
713
  logs: logTrackingResults // Include log tracking results
624
714
  };
715
+
716
+ logger.info('Recording stopped with clientStartDate', {
717
+ clientStartDate: recordingStartTime,
718
+ clientStartDateReadable: new Date(recordingStartTime).toISOString(),
719
+ duration: result.duration
720
+ });
625
721
 
626
722
  currentRecording = null;
627
723
  recordingStartTime = null;
@@ -673,3 +769,8 @@ export function getRecordingStatus() {
673
769
  outputPath
674
770
  };
675
771
  }
772
+
773
+ /**
774
+ * Export the fix function for external use
775
+ */
776
+ export { fixVideoContainer };
@@ -1,5 +1,6 @@
1
1
  import { Tail } from 'tail';
2
2
  import { logger } from '../logger.js';
3
+ import fs from 'fs';
3
4
 
4
5
  // Simple function to get stats for events in the last minute
5
6
  function getStats(eventTimes = []) {
@@ -40,6 +41,12 @@ export class FileTracker {
40
41
  this.trackedFile = trackedFile;
41
42
 
42
43
  try {
44
+ // Ensure the file exists before creating the Tail watcher
45
+ if (!fs.existsSync(this.trackedFile)) {
46
+ logger.warn(`File does not exist, creating: ${this.trackedFile}`);
47
+ fs.writeFileSync(this.trackedFile, '', 'utf8');
48
+ }
49
+
43
50
  this.tail = new Tail(this.trackedFile, { encoding: 'ascii' });
44
51
  this.tail.on('line', (line) => {
45
52
  const time = Date.now();
@@ -55,6 +55,7 @@ export class LogsTracker {
55
55
 
56
56
  this.files[filePath] = {
57
57
  status,
58
+ filePath, // Store file path for later reference
58
59
  unsubscribe: this.fileTrackerManager.subscribe(filePath, callback),
59
60
  };
60
61
 
@@ -98,13 +99,25 @@ export class LogsTracker {
98
99
 
99
100
  getStatus() {
100
101
  let items = [];
102
+ let filePathMap = {};
103
+
101
104
  if (this.isWatchOnly) {
102
- items = Object.keys(this.files).map((filePath) => ({
103
- ...this.fileTrackerManager.getStats(filePath),
104
- item: this.fileToIndex[filePath],
105
- }));
105
+ items = Object.keys(this.files).map((filePath) => {
106
+ const index = this.fileToIndex[filePath];
107
+ filePathMap[index] = filePath;
108
+ return {
109
+ ...this.fileTrackerManager.getStats(filePath),
110
+ item: index, // Keep numeric index to match events
111
+ };
112
+ });
106
113
  } else {
107
- items = Object.values(this.files).map(({ status }) => status);
114
+ items = Object.values(this.files).map(({ status, filePath }) => {
115
+ const index = status.item;
116
+ filePathMap[index] = filePath;
117
+ return {
118
+ ...status,
119
+ };
120
+ });
108
121
  }
109
122
 
110
123
  const totalCount = items.reduce((acc, status) => acc + status.count, 0);
@@ -112,11 +125,12 @@ export class LogsTracker {
112
125
  return [
113
126
  {
114
127
  id: 'CLI',
115
- name: 'CLI',
128
+ name: 'File Logs', // More descriptive name
116
129
  type: 'cli',
117
130
  fileLocation: this.fileLocation,
118
131
  items: items,
119
132
  count: totalCount,
133
+ filePathMap: filePathMap, // Include mapping for UI to display file paths
120
134
  },
121
135
  ];
122
136
  }
@@ -126,7 +140,7 @@ export class LogsTracker {
126
140
  const status = this.getStatus();
127
141
  return status.length > 0 ? status[0] : {
128
142
  id: 'CLI',
129
- name: 'CLI',
143
+ name: 'File Logs',
130
144
  type: 'cli',
131
145
  fileLocation: this.fileLocation,
132
146
  items: [],
@@ -20,8 +20,9 @@ async function ensureIconModule() {
20
20
  const windowsModule = await import("./windows.js");
21
21
  getIconAsBuffer = windowsModule.getIconAsBuffer;
22
22
  } else {
23
- // Linux fallback
24
- getIconAsBuffer = () => null;
23
+ // Linux support
24
+ const linuxModule = await import("./linux.js");
25
+ getIconAsBuffer = linuxModule.getIconAsBuffer;
25
26
  }
26
27
 
27
28
  iconModuleLoaded = true;