markupr 2.1.8

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.
Files changed (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * FrameExtractor.ts - Video Frame Extraction via ffmpeg
3
+ *
4
+ * Extracts PNG frames from a video file at specific timestamps using the
5
+ * system-installed ffmpeg binary. Degrades gracefully if ffmpeg is not
6
+ * available (returns empty result with ffmpegAvailable: false).
7
+ *
8
+ * Part of the post-processing pipeline that runs after recording stops.
9
+ */
10
+
11
+ import { execFile as execFileCb } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { existsSync, mkdirSync } from 'fs';
14
+ import { stat as statFile } from 'fs/promises';
15
+ import { join } from 'path';
16
+
17
+ const execFile = promisify(execFileCb);
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface FrameExtractionRequest {
24
+ videoPath: string;
25
+ timestamps: number[]; // seconds from start of recording
26
+ outputDir: string; // directory to save PNGs
27
+ maxFrames?: number; // cap at 20 by default
28
+ }
29
+
30
+ export interface FrameExtractionResult {
31
+ frames: Array<{
32
+ path: string;
33
+ timestamp: number;
34
+ success: boolean;
35
+ }>;
36
+ ffmpegAvailable: boolean;
37
+ }
38
+
39
+ // ============================================================================
40
+ // Constants
41
+ // ============================================================================
42
+
43
+ /** Default maximum number of frames to extract */
44
+ const DEFAULT_MAX_FRAMES = 20;
45
+
46
+ /** Timeout for decode-accurate frame extraction */
47
+ const FFMPEG_ACCURATE_FRAME_TIMEOUT_MS = 20_000;
48
+
49
+ /** Timeout for fast-seek fallback extraction */
50
+ const FFMPEG_FAST_FRAME_TIMEOUT_MS = 10_000;
51
+
52
+ /** Timeout for ffmpeg version check (5 seconds) */
53
+ const FFMPEG_CHECK_TIMEOUT_MS = 5_000;
54
+
55
+ /** Avoid extracting on startup/teardown edge frames, which are often corrupted */
56
+ const FRAME_EDGE_MARGIN_SECONDS = 0.35;
57
+
58
+ /** Collapse nearly-identical timestamps after clamping */
59
+ const TIMESTAMP_DEDUPE_WINDOW_SECONDS = 0.15;
60
+
61
+ // ============================================================================
62
+ // FrameExtractor Class
63
+ // ============================================================================
64
+
65
+ export class FrameExtractor {
66
+ private ffmpegPath: string = 'ffmpeg';
67
+ private ffprobePath: string = 'ffprobe';
68
+ private ffmpegChecked: boolean = false;
69
+ private ffmpegAvailable: boolean = false;
70
+
71
+ /**
72
+ * Check if ffmpeg is installed and accessible on the system PATH.
73
+ * Result is cached after the first successful check.
74
+ */
75
+ async checkFfmpeg(): Promise<boolean> {
76
+ if (this.ffmpegChecked) {
77
+ return this.ffmpegAvailable;
78
+ }
79
+
80
+ try {
81
+ await execFile(this.ffmpegPath, ['-version'], {
82
+ timeout: FFMPEG_CHECK_TIMEOUT_MS,
83
+ });
84
+ this.ffmpegAvailable = true;
85
+ this.log('ffmpeg is available');
86
+ } catch {
87
+ this.ffmpegAvailable = false;
88
+ this.log('ffmpeg is not available - frame extraction will be skipped');
89
+ }
90
+
91
+ this.ffmpegChecked = true;
92
+ return this.ffmpegAvailable;
93
+ }
94
+
95
+ /**
96
+ * Extract frames from a video file at the specified timestamps.
97
+ *
98
+ * @param request - Extraction parameters (video path, timestamps, output dir)
99
+ * @returns Result with extracted frame paths and ffmpeg availability status
100
+ */
101
+ async extract(request: FrameExtractionRequest): Promise<FrameExtractionResult> {
102
+ const available = await this.checkFfmpeg();
103
+
104
+ if (!available) {
105
+ return { frames: [], ffmpegAvailable: false };
106
+ }
107
+
108
+ const maxFrames = request.maxFrames ?? DEFAULT_MAX_FRAMES;
109
+
110
+ // Cap timestamps to maxFrames, keeping evenly distributed ones
111
+ let timestamps = [...request.timestamps].sort((a, b) => a - b);
112
+ if (timestamps.length > maxFrames) {
113
+ timestamps = this.selectDistributed(timestamps, maxFrames);
114
+ }
115
+
116
+ const videoDurationSeconds = await this.getVideoDurationSeconds(request.videoPath);
117
+ timestamps = this.normalizeTimestamps(timestamps, videoDurationSeconds);
118
+ if (timestamps.length === 0) {
119
+ return { frames: [], ffmpegAvailable: true };
120
+ }
121
+
122
+ // Ensure the screenshots subdirectory exists
123
+ const screenshotsDir = join(request.outputDir, 'screenshots');
124
+ if (!existsSync(screenshotsDir)) {
125
+ mkdirSync(screenshotsDir, { recursive: true });
126
+ }
127
+
128
+ // Extract each frame
129
+ const frames: FrameExtractionResult['frames'] = [];
130
+
131
+ for (let i = 0; i < timestamps.length; i++) {
132
+ const timestamp = timestamps[i];
133
+ const frameNumber = String(i + 1).padStart(3, '0');
134
+ const outputPath = join(screenshotsDir, `frame-${frameNumber}.png`);
135
+
136
+ try {
137
+ await this.extractSingleFrame(request.videoPath, timestamp, outputPath);
138
+
139
+ const stats = await statFile(outputPath).catch(() => null);
140
+ if (!stats || stats.size <= 0) {
141
+ throw new Error('ffmpeg did not produce a frame file');
142
+ }
143
+
144
+ frames.push({
145
+ path: outputPath,
146
+ timestamp,
147
+ success: true,
148
+ });
149
+
150
+ this.log(`Extracted frame ${frameNumber} at ${timestamp.toFixed(2)}s`);
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ this.log(`Failed to extract frame at ${timestamp.toFixed(2)}s: ${message}`);
154
+
155
+ frames.push({
156
+ path: outputPath,
157
+ timestamp,
158
+ success: false,
159
+ });
160
+ }
161
+ }
162
+
163
+ return { frames, ffmpegAvailable: true };
164
+ }
165
+
166
+ // ============================================================================
167
+ // Private Methods
168
+ // ============================================================================
169
+
170
+ /**
171
+ * Extract a single frame from the video at the given timestamp.
172
+ */
173
+ private async extractSingleFrame(
174
+ videoPath: string,
175
+ timestamp: number,
176
+ outputPath: string
177
+ ): Promise<void> {
178
+ // Prefer decode-accurate extraction (-ss after -i) to avoid VP8/VP9
179
+ // keyframe seek artifacts. Fall back to fast seek if needed.
180
+ try {
181
+ await this.extractSingleFrameAccurate(videoPath, timestamp, outputPath);
182
+ return;
183
+ } catch (accurateError) {
184
+ this.log(
185
+ `Accurate extraction failed at ${timestamp.toFixed(2)}s, retrying fast seek: ${
186
+ accurateError instanceof Error ? accurateError.message : String(accurateError)
187
+ }`
188
+ );
189
+ }
190
+
191
+ await this.extractSingleFrameFast(videoPath, timestamp, outputPath);
192
+ }
193
+
194
+ private async extractSingleFrameAccurate(
195
+ videoPath: string,
196
+ timestamp: number,
197
+ outputPath: string
198
+ ): Promise<void> {
199
+ const args = [
200
+ '-i', videoPath,
201
+ '-ss', String(timestamp),
202
+ '-frames:v', '1',
203
+ '-vf', 'format=rgb24',
204
+ '-q:v', '2',
205
+ '-y',
206
+ outputPath,
207
+ ];
208
+
209
+ await execFile(this.ffmpegPath, args, {
210
+ timeout: FFMPEG_ACCURATE_FRAME_TIMEOUT_MS,
211
+ });
212
+ }
213
+
214
+ private async extractSingleFrameFast(
215
+ videoPath: string,
216
+ timestamp: number,
217
+ outputPath: string
218
+ ): Promise<void> {
219
+ const args = [
220
+ '-ss', String(timestamp),
221
+ '-i', videoPath,
222
+ '-frames:v', '1',
223
+ '-vf', 'format=rgb24',
224
+ '-q:v', '2',
225
+ '-y', // overwrite output file if it exists
226
+ outputPath,
227
+ ];
228
+
229
+ await execFile(this.ffmpegPath, args, {
230
+ timeout: FFMPEG_FAST_FRAME_TIMEOUT_MS,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Select evenly distributed timestamps from a sorted array.
236
+ * Always includes the first and last timestamp.
237
+ */
238
+ private selectDistributed(sorted: number[], count: number): number[] {
239
+ if (sorted.length <= count) {
240
+ return sorted;
241
+ }
242
+
243
+ if (count <= 0) {
244
+ return [];
245
+ }
246
+
247
+ if (count === 1) {
248
+ return [sorted[0]];
249
+ }
250
+
251
+ const result: number[] = [sorted[0]];
252
+ const step = (sorted.length - 1) / (count - 1);
253
+
254
+ for (let i = 1; i < count - 1; i++) {
255
+ const index = Math.round(i * step);
256
+ result.push(sorted[index]);
257
+ }
258
+
259
+ result.push(sorted[sorted.length - 1]);
260
+ return result;
261
+ }
262
+
263
+ private async getVideoDurationSeconds(videoPath: string): Promise<number | null> {
264
+ try {
265
+ const { stdout } = await execFile(
266
+ this.ffprobePath,
267
+ [
268
+ '-v', 'error',
269
+ '-show_entries', 'format=duration',
270
+ '-of', 'default=noprint_wrappers=1:nokey=1',
271
+ videoPath,
272
+ ],
273
+ { timeout: FFMPEG_CHECK_TIMEOUT_MS }
274
+ );
275
+ const parsed = Number.parseFloat(String(stdout).trim());
276
+ if (Number.isFinite(parsed) && parsed > 0) {
277
+ return parsed;
278
+ }
279
+ return null;
280
+ } catch (error) {
281
+ this.log(`ffprobe duration probe failed: ${error instanceof Error ? error.message : String(error)}`);
282
+ return null;
283
+ }
284
+ }
285
+
286
+ private normalizeTimestamps(timestamps: number[], durationSeconds: number | null): number[] {
287
+ const cleaned = timestamps
288
+ .map((timestamp) => (Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0))
289
+ .sort((a, b) => a - b);
290
+
291
+ if (cleaned.length === 0) {
292
+ return [];
293
+ }
294
+
295
+ let clamped = cleaned;
296
+ if (durationSeconds && durationSeconds > 0) {
297
+ const minTs = Math.min(FRAME_EDGE_MARGIN_SECONDS, Math.max(0, durationSeconds - 0.05));
298
+ const maxTs = Math.max(minTs, durationSeconds - FRAME_EDGE_MARGIN_SECONDS);
299
+ clamped = cleaned.map((timestamp) => Math.max(minTs, Math.min(timestamp, maxTs)));
300
+ }
301
+
302
+ const deduped: number[] = [];
303
+ for (const timestamp of clamped) {
304
+ const previous = deduped[deduped.length - 1];
305
+ if (
306
+ previous === undefined ||
307
+ Math.abs(timestamp - previous) >= TIMESTAMP_DEDUPE_WINDOW_SECONDS
308
+ ) {
309
+ deduped.push(timestamp);
310
+ }
311
+ }
312
+
313
+ return deduped;
314
+ }
315
+
316
+ /**
317
+ * Log helper with consistent prefix.
318
+ */
319
+ private log(message: string): void {
320
+ const timestamp = new Date().toISOString();
321
+ console.log(`[FrameExtractor ${timestamp}] ${message}`);
322
+ }
323
+ }
324
+
325
+ // ============================================================================
326
+ // Singleton Export
327
+ // ============================================================================
328
+
329
+ export const frameExtractor = new FrameExtractor();
330
+ export default FrameExtractor;